Entendiendo Closures en React: La clave detrás de los Hooks

Descubre cómo las closures de JavaScript hacen posible los Hooks de React y aprende a utilizarlas para crear componentes más predecibles, mantenibles y eficientes.

Entendiendo Closures en React: La clave detrás de los Hooks
Photo by Anne Nygård / Unsplash

Introducción

El lanzamiento de React Hooks en 2019 revolucionó la forma en que escribimos componentes. De pronto, la gestión de estado y efectos secundarios en componentes funcionales se volvió accesible sin necesidad de clases. Esta API elegante y declarativa simplificó nuestro código, pero bajo su aparente simplicidad se esconde un concepto fundamental de JavaScript que muchos desarrolladores utilizan sin comprender completamente: las closures.

Las closures no son un concepto nuevo ni exclusivo de React—son una característica fundamental de JavaScript que ha existido desde sus inicios. Sin embargo, los creadores de React aprovecharon ingeniosamente este mecanismo para construir la API de Hooks que utilizamos diariamente.

En mi experiencia como desarrollador y mentor, he observado que entender las closures no solo mejora nuestra comprensión de cómo funcionan internamente los Hooks, sino que también nos ayuda a:

  • Diagnosticar y resolver problemas comunes en componentes de React
  • Crear Hooks personalizados más efectivos y mantenibles
  • Evitar patrones que generan comportamientos inesperados
  • Optimizar el rendimiento de nuestras aplicaciones

Este artículo te guiará desde los fundamentos de las closures en JavaScript hasta su aplicación avanzada en el ecosistema de React. Si alguna vez te has preguntado por qué el array de dependencias en useEffect es tan importante o has experimentado inconsistencias extrañas en el estado de tus componentes, las respuestas a menudo se encuentran en la manera en que las closures interactúan con el ciclo de vida de React.

Fundamentos de Closures en JavaScript

Definición y mecánica

Una closure es una función que mantiene acceso al entorno léxico en el que fue creada, incluso después de que ese entorno haya terminado su ejecución. En términos más simples: una función que "recuerda" el entorno donde nació.

Esta definición puede parecer abstracta, así que veamos un ejemplo básico:

function crearContador() {
  let contador = 0

  function incrementar() {
    contador++
    return contador
  }

  return incrementar
}

const miContador = crearContador()
console.log(miContador()) // 1
console.log(miContador()) // 2

En este ejemplo, miContador es una closure. Aunque crearContador terminó su ejecución, la función incrementar que retornó mantiene acceso a la variable contador definida en su entorno de creación.

El entorno léxico y la cadena de alcance

Para entender completamente las closures, necesitamos comprender dos conceptos fundamentales:

  • Entorno léxico: El contexto que contiene las variables definidas en el ámbito donde se crea una función
  • Cadena de alcance: La jerarquía de entornos léxicos que JavaScript consulta cuando busca una variable

JavaScript utiliza ámbito léxico, lo que significa que el alcance de las variables se determina por su posición en el código fuente, no por la secuencia de llamadas a funciones durante la ejecución.

function externa() {
  const mensaje = "Hola desde función externa"
  
  function interna() {
    console.log(mensaje)
  }
  
  return interna
}

const fn = externa()
fn() // "Hola desde función externa"

En este ejemplo, la cadena de alcance de interna incluye tanto su propio entorno léxico como el de externa. Cuando se busca la variable mensaje dentro de interna, JavaScript sigue esta cadena hasta encontrarla.

Función que recuerda su contexto

Las closures son especialmente útiles cuando necesitamos encapsular estado y comportamiento. Veamos otro ejemplo práctico:

function crearSaludo(saludo) {
  return function(nombre) {
    return `${saludo}, ${nombre}!`
  }
}

const saludarFormal = crearSaludo("Buenos días")
const saludarInformal = crearSaludo("Hola")

console.log(saludarFormal("María")) // "Buenos días, María!"
console.log(saludarInformal("Carlos")) // "Hola, Carlos!"

En este caso, hemos creado dos closures distintas (saludarFormal y saludarInformal) que "recuerdan" diferentes valores para saludo. Este patrón es increíblemente poderoso y forma la base de cómo los Hooks mantienen estado en componentes funcionales.

💡 Pro tip
Las closures no solo "recuerdan" variables, sino el valor específico de esas variables en el momento en que se creó la closure. Esto será crucial para entender problemas comunes con dependencias en React Hooks.

Cómo React utiliza Closures

El modelo mental detrás de los Hooks

Antes de los Hooks, los componentes funcionales en React eran "sin estado" (stateless) y se ejecutaban completamente de arriba a abajo en cada renderizado, sin "recordar" nada entre renderizados. Los Hooks cambiaron esto fundamentalmente, permitiendo a los componentes funcionales "recordar" estado y comportamiento.

¿Cómo logra React este comportamiento? La respuesta está en las closures.

Cada vez que un componente funcional se renderiza, React crea un nuevo entorno léxico. Los Hooks que utilizamos dentro del componente forman closures que "recuerdan" el estado específico de ese renderizado.

function Contador() {
  const [contador, setContador] = React.useState(0)
  
  const incrementar = () => {
    setContador(contador + 1)
  }
  
  return (
    <div>
      <p>Valor: {contador}</p>
      <button onClick={incrementar}>Incrementar</button>
    </div>
  )
}

En cada renderizado de Contador, se crea una nueva función incrementar que forma una closure sobre el valor actual de contador en ese renderizado específico.

Explorando useState: una closure en acción

El Hook useState es quizás el ejemplo más claro de cómo React utiliza closures. Aunque su implementación real es más compleja, podemos conceptualizarlo así:

// Pseudocódigo simplificado de cómo React implementa useState
let estadoComponente = []; // Array global donde React almacena el estado
let indiceActual = 0; // Índice para rastrear el Hook actual

function useState(valorInicial) {
  const indice = indiceActual; // Captura el índice actual
  indiceActual++; // Prepara para el siguiente Hook
  
  // Inicializa el estado si es el primer renderizado
  if (estadoComponente[indice] === undefined) {
    estadoComponente[indice] = valorInicial;
  }
  
  // Función para actualizar el estado (closure)
  const setState = (nuevoValor) => {
    estadoComponente[indice] = nuevoValor;
    renderizarComponente(); // Trigger para re-renderizar
  };
  
  return [estadoComponente[indice], setState];
}

// Reset del índice antes de cada renderizado
function renderizarComponente() {
  indiceActual = 0;
  // Renderiza el componente...
}

Cuando llamamos a useState, obtenemos una función setState que forma una closure sobre el índice específico asignado a ese Hook. Esta closure "recuerda" exactamente qué parte del estado debe actualizar, incluso después de que el componente haya terminado de renderizarse.

El array de dependencias de useEffect como manifestación de closures

El Hook useEffect también depende fundamentalmente de closures, y aquí es donde muchos desarrolladores encuentran confusiones y bugs sutiles.

function PerfilUsuario({ userId }) {
  const [usuario, setUsuario] = useState(null)
  
  useEffect(() => {
    async function cargarUsuario() {
      const respuesta = await fetch(`/api/usuarios/${userId}`)
      const datos = await respuesta.json()
      setUsuario(datos)
    }
    
    cargarUsuario()
  }, [userId]) // Array de dependencias
  
  // Resto del componente...
}

En este ejemplo, la callback proporcionada a useEffect forma una closure sobre userId y setUsuario. El array de dependencias [userId] le dice a React que debe recrear esta closure cada vez que userId cambie.

¿Por qué es esto importante? Porque sin el array de dependencias correcto, podríamos estar utilizando valores desactualizados (stale) capturados en una closure anterior.

⚠️ Advertencia
El linter de ESLint con la configuración de React (eslint-plugin-react-hooks) puede ayudarte a detectar dependencias faltantes en tus efectos, pero es crucial entender el mecanismo subyacente de las closures para escribir código predecible.

Patrones comunes con Closures en React

Creación de hooks personalizados efectivos

Los Hooks personalizados son una de las características más poderosas de React, permitiéndonos extraer y reutilizar lógica con estado. Las closures hacen que este patrón sea posible y efectivo.

function useContador(valorInicial = 0, paso = 1) {
  const [valor, setValor] = useState(valorInicial)
  
  const incrementar = useCallback(() => {
    setValor(v => v + paso)
  }, [paso])
  
  const decrementar = useCallback(() => {
    setValor(v => v - paso)
  }, [paso])
  
  const resetear = useCallback(() => {
    setValor(valorInicial)
  }, [valorInicial])
  
  return { valor, incrementar, decrementar, resetear }
}

En este Hook personalizado, las funciones incrementar, decrementar y resetear son closures que capturan paso y valorInicial. Usamos useCallback para asegurarnos de que estas closures se recreen solo cuando sus dependencias cambien.

Gestión de estado derivado utilizando closures

Las closures son especialmente útiles para calcular estado derivado—valores que dependen del estado principal pero no necesitan ser almacenados directamente.

function ListaFiltrable({ items }) {
  const [busqueda, setBusqueda] = useState("")
  
  // Estado derivado calculado en cada renderizado
  const itemsFiltrados = useMemo(() => {
    return items.filter(item => 
      item.nombre.toLowerCase().includes(busqueda.toLowerCase())
    )
  }, [items, busqueda])
  
  return (
    <div>
      <input 
        value={busqueda} 
        onChange={e => setBusqueda(e.target.value)} 
        placeholder="Buscar..." 
      />
      <ul>
        {itemsFiltrados.map(item => (
          <li key={item.id}>{item.nombre}</li>
        ))}
      </ul>
    </div>
  )
}

En este ejemplo, useMemo crea una closure que captura items y busqueda, calculando itemsFiltrados solo cuando estas dependencias cambian.

Evitar "stale closures" en useEffect y callbacks

Uno de los problemas más comunes relacionados con closures en React es el de las "stale closures" o closures desactualizadas. Esto ocurre cuando una closure "recuerda" un valor que ya no está sincronizado con el estado actual del componente.

Considera este ejemplo problemático:

function Contador() {
  const [contador, setContador] = useState(0)
  
  // ❌ Problema: closure desactualizada
  useEffect(() => {
    const intervalo = setInterval(() => {
      console.log(`El contador es: ${contador}`)
      setContador(contador + 1) // Siempre incrementa desde el valor inicial
    }, 1000)
    
    return () => clearInterval(intervalo)
  }, []) // Array de dependencias vacío
  
  return <div>Contador: {contador}</div>
}

En este código, la función pasada a setInterval forma una closure sobre contador con su valor inicial (0). Debido al array de dependencias vacío, esta closure nunca se actualiza, por lo que siempre verá contador como 0, incluso después de múltiples incrementos.

La solución correcta sería:

function Contador() {
  const [contador, setContador] = useState(0)
  
  // ✅ Solución 1: incluir contador en las dependencias
  useEffect(() => {
    const intervalo = setInterval(() => {
      console.log(`El contador es: ${contador}`)
      setContador(contador + 1)
    }, 1000)
    
    return () => clearInterval(intervalo)
  }, [contador]) // Array de dependencias con contador
  
  return <div>Contador: {contador}</div>
}

O mejor aún, usar la forma funcional de setContador:

function Contador() {
  const [contador, setContador] = useState(0)
  
  // ✅ Solución 2: usar la forma funcional de setState
  useEffect(() => {
    const intervalo = setInterval(() => {
      setContador(c => c + 1) // No depende del valor actual de contador
    }, 1000)
    
    return () => clearInterval(intervalo)
  }, []) // Array de dependencias vacío es válido aquí
  
  return <div>Contador: {contador}</div>
}

La forma funcional de setContador nos permite actualizar el estado basándonos en su valor más reciente, evitando el problema de las closures desactualizadas.

💡 Pro tip
Siempre que necesites acceder al valor más actualizado de un estado dentro de una closure que podría perdurar en el tiempo (como en eventos, temporizadores o suscripciones), considera usar la forma funcional de setState o asegúrate de que la closure se recree cuando el estado cambie.

Debugging de problemas relacionados con Closures

Síntomas comunes de problemas con closures

Los problemas relacionados con closures en React suelen manifestarse de maneras específicas:

  1. Valores "congelados": Un efecto o callback que siempre usa el mismo valor aunque el estado haya cambiado
  2. Actualizaciones perdidas: Cambios de estado que parecen ignorarse o "comerse" entre sí
  3. Comportamiento inconsistente: Componentes que funcionan de manera diferente según el orden de las interacciones
  4. Actualizaciones infinitas: Efectos que se ejecutan en bucle sin una causa aparente

Estos problemas generalmente se deben a una gestión incorrecta de las dependencias en Hooks como useEffect, useCallback o useMemo.

Herramientas de diagnóstico

Para diagnosticar problemas relacionados con closures, podemos utilizar varias estrategias:

  1. React DevTools: Examinar qué props y estado tiene un componente en cada momento
  2. Puntos de interrupción y console.log estratégicos: Para verificar qué valores están capturados en una closure
  3. ESLint con plugin react-hooks: Para detectar dependencias faltantes en arrays de dependencias
  4. Depuración por reconstrucción: Simplificar el componente y agregar complejidad progresivamente hasta identificar el problema
// Técnica de depuración con console.log para rastrear closures
useEffect(() => {
  console.log('Efecto recreado con valores:', { contador, props, otrasVariables })
  // Resto del efecto...
}, [contador, props, otrasVariables])

Técnicas de refactorización para resolver problemas

Una vez identificado un problema relacionado con closures, podemos aplicar varias técnicas para resolverlo:

  1. Corregir arrays de dependencias: Asegurarnos de que todos los valores utilizados en un Hook estén incluidos en su array de dependencias
// ❌ Antes
useEffect(() => {
  fetchData(userId)
}, []) // Dependencia faltante

// ✅ Después
useEffect(() => {
  fetchData(userId)
}, [userId]) // Dependencia incluida correctamente
  1. Usar la forma funcional de setState: Para actualizaciones que dependan del valor anterior
// ❌ Antes
const incrementar = () => setContador(contador + 1)

// ✅ Después
const incrementar = () => setContador(c => c + 1)
  1. Mover valores a useRef: Cuando necesitamos un valor que se actualice sin recrear efectos
function Componente() {
  const [valor, setValor] = useState(0)
  // useRef no causa recreación de efectos cuando cambia
  const valorRef = useRef(valor)
  
  // Actualizar la ref cuando el estado cambie
  useEffect(() => {
    valorRef.current = valor
  }, [valor])
  
  useEffect(() => {
    const intervalo = setInterval(() => {
      // Siempre accede al valor más reciente
      console.log(valorRef.current)
    }, 1000)
    
    return () => clearInterval(intervalo)
  }, []) // No depende de valor
}
  1. Descomponer efectos complejos: Separar efectos con diferentes ciclos de vida
// ❌ Antes: un efecto con múltiples responsabilidades
useEffect(() => {
  fetchUsuario(userId)
  trackVisitaPagina()
  const subscription = subscribe()
  return () => unsubscribe(subscription)
}, [userId])

// ✅ Después: efectos separados con dependencias claras
useEffect(() => {
  fetchUsuario(userId)
}, [userId])

useEffect(() => {
  trackVisitaPagina()
}, [])

useEffect(() => {
  const subscription = subscribe()
  return () => unsubscribe(subscription)
}, [])

Closures avanzadas: Implementación de hooks complejos

Creación de un custom hook para gestión de formularios

Veamos cómo las closures nos permiten crear un Hook personalizado potente para gestionar formularios:

function useFormulario(valoresIniciales = {}) {
  const [valores, setValores] = useState(valoresIniciales)
  const [errores, setErrores] = useState({})
  const [tocados, setTocados] = useState({})
  
  // Reset del formulario
  const resetear = useCallback(() => {
    setValores(valoresIniciales)
    setErrores({})
    setTocados({})
  }, [valoresIniciales])
  
  // Manejar cambios en campo individual
  const manejarCambio = useCallback((e) => {
    const { name, value } = e.target
    
    setValores(valoresAnteriores => ({
      ...valoresAnteriores,
      [name]: value
    }))
  }, [])
  
  // Marcar campo como tocado al perder el foco
  const manejarBlur = useCallback((e) => {
    const { name } = e.target
    
    setTocados(tocadosAnteriores => ({
      ...tocadosAnteriores,
      [name]: true
    }))
  }, [])
  
  // Validar el formulario
  const validar = useCallback((reglasValidacion = {}) => {
    const nuevosErrores = {}
    
    Object.keys(reglasValidacion).forEach(campo => {
      const valor = valores[campo]
      const validacion = reglasValidacion[campo]
      const error = validacion(valor, valores)
      
      if (error) {
        nuevosErrores[campo] = error
      }
    })
    
    setErrores(nuevosErrores)
    return Object.keys(nuevosErrores).length === 0
  }, [valores])
  
  return {
    valores,
    errores,
    tocados,
    manejarCambio,
    manejarBlur,
    resetear,
    validar
  }
}

Este Hook personalizado utiliza closures para:

  • Encapsular múltiples estados relacionados con el formulario
  • Proporcionar métodos que "recuerdan" cómo acceder y actualizar estos estados
  • Mantener la validación sincronizada con los valores actuales

Ejemplo de uso:

function FormularioRegistro() {
  const {
    valores,
    errores,
    tocados,
    manejarCambio,
    manejarBlur,
    resetear,
    validar
  } = useFormulario({
    nombre: '',
    email: '',
    password: ''
  })
  
  const reglas = {
    nombre: valor => !valor ? 'El nombre es requerido' : null,
    email: valor => !valor.includes('@') ? 'Email inválido' : null,
    password: valor => valor.length < 6 ? 'La contraseña debe tener al menos 6 caracteres' : null
  }
  
  const manejarEnvio = (e) => {
    e.preventDefault()
    
    if (validar(reglas)) {
      console.log('Formulario enviado:', valores)
      resetear()
    }
  }
  
  return (
    <form onSubmit={manejarEnvio}>
      <div>
        <label>Nombre:</label>
        <input
          name="nombre"
          value={valores.nombre}
          onChange={manejarCambio}
          onBlur={manejarBlur}
        />
        {tocados.nombre && errores.nombre && (
          <span className="error">{errores.nombre}</span>
        )}
      </div>
      
      {/* Campos adicionales */}
      
      <button type="submit">Registrarse</button>
    </form>
  )
}

Implementar cache y memoización con closures

Las closures son perfectas para implementar mecanismos de cache y memoización en componentes. Veamos un ejemplo de un Hook personalizado que implementa una cache simple:

function useMemoizacion(fn, dependencias) {
  // Ref para almacenar el cache entre renderizados
  const cacheRef = useRef({})
  
  // Recrear la función memoizada cuando cambien las dependencias
  return useCallback((...args) => {
    // Crear una clave única basada en los argumentos
    const clave = JSON.stringify(args)
    
    // Verificar si ya tenemos un resultado en cache
    if (cacheRef.current[clave] === undefined) {
      // Si no existe, calcular y almacenar
      cacheRef.current[clave] = fn(...args)
    }
    
    return cacheRef.current[clave]
  }, dependencias)
}

Este Hook crea una closure sobre cacheRef y fn, permitiendo acceder a resultados previamente calculados sin repetir cálculos costosos.

Ejemplo de uso:

function ComponenteConCalculosCostosos({ data, multiplicador }) {
  // Memoizar el cálculo costoso
  const calcularResultado = useMemoizacion(
    (items, factor) => {
      console.log('Ejecutando cálculo costoso...')
      return items.map(item => ({
        ...item,
        valor: item.base * factor
      }))
    },
    [multiplicador]
  )
  
  const resultados = calcularResultado(data, multiplicador)
  
  return (
    <ul>
      {resultados.map(item => (
        <li key={item.id}>
          {item.nombre}: {item.valor}
        </li>
      ))}
    </ul>
  )
}

Patrones de suscripción y limpieza

Las closures son esenciales para implementar patrones de suscripción que puedan limpiarse adecuadamente. Veamos un Hook personalizado para gestionar eventos globales:

function useEventoGlobal(evento, manejador, opciones = {}) {
  // Guardar referencia al manejador para evitar recrear el efecto
  // cuando solo el manejador cambia
  const manejadorRef = useRef(manejador)
  
  // Actualizar la referencia cuando cambie el manejador
  useEffect(() => {
    manejadorRef.current = manejador
  }, [manejador])
  
  useEffect(() => {
    // Función de wrapper que llama a la versión más reciente
    // del manejador
    const eventHandler = (e) => {
      manejadorRef.current(e)
    }
    
    // Suscribirse al evento
    window.addEventListener(evento, eventHandler, opciones)
    
    // Closure para limpieza que "recuerda" el manejador exacto
    // que necesita eliminar
    return () => {
      window.removeEventListener(evento, eventHandler, opciones)
    }
  }, [evento, opciones])
}

Este patrón es potente porque:

  1. Garantiza que siempre se use la versión más actualizada del manejador
  2. Evita recreaciones innecesarias del efecto de suscripción
  3. Asegura que la limpieza se realice correctamente al desmontar

Ejemplo de uso:

function ComponenteSensibleARedimension() {
  const [dimensiones, setDimensiones] = useState({
    ancho: window.innerWidth,
    alto: window.innerHeight
  })
  
  // Manejador que se recrea en cada renderizado
  const actualizarDimensiones = () => {
    setDimensiones({
      ancho: window.innerWidth,
      alto: window.innerHeight
    })
  }
  
  // Hook que gestiona la suscripción y limpieza
  useEventoGlobal('resize', actualizarDimensiones)
  
  return (
    <div>
      Ancho: {dimensiones.ancho}px, Alto: {dimensiones.alto}px
    </div>
  )
}

Conclusión

Síntesis de aprendizajes

A lo largo de este artículo, hemos explorado cómo las closures—un concepto fundamental de JavaScript—son el mecanismo que hace posible la API de Hooks en React. Hemos visto que:

  1. Las closures permiten a funciones "recordar" y acceder a su entorno léxico original
  2. React aprovecha las closures para mantener estado y efectos en componentes funcionales
  3. El array de dependencias en Hooks como useEffect y useCallback determina cuándo se recrean las closures
  4. Los problemas comunes relacionados con closures suelen deberse a arrays de dependencias incorrectos
  5. Las closures son poderosas para crear Hooks personalizados complejos

Entender las closures no es solo un ejercicio académico—es una habilidad práctica que mejora nuestra capacidad para trabajar efectivamente con React moderno.

Recursos para seguir profundizando

Para continuar tu aprendizaje sobre closures y su aplicación en React, te recomendamos:

Próximos pasos para mejorar tu comprensión de React

Para llevar tu comprensión de React al siguiente nivel:

  1. Experimenta con closures: Intenta implementar tus propios Hooks personalizados para resolver problemas específicos
  2. Analiza código existente: Examina bibliotecas populares de Hooks y entiende cómo utilizan closures
  3. Practica depuración: Identifica y resuelve intencionalmente problemas relacionados con closures
  4. Comparte conocimiento: Explica estos conceptos a otros desarrolladores—enseñar es una de las mejores formas de aprender
💡 Pro tip
Una excelente práctica es implementar versiones simplificadas de Hooks de React, como useState o useEffect, para entender profundamente cómo funcionan internamente.

Las closures son uno de esos conceptos fundamentales que, una vez dominados, transforman tu forma de pensar sobre el código. En React, te permiten escribir componentes más limpios, mantenibles y predecibles. Espero que este artículo te haya ayudado a dar un paso importante hacia esa maestría.