No mostrar, no montar y no cargar

No mostrar, no montar y no cargar

Cuando una página se siente lenta en un teléfono, el reflejo natural es pensar en el tamaño de los archivos. Y tiene sentido: una imagen de 1,7 MB pesa, un paquete de 600 KB pesa. Pero hay una capa de trabajo que no aparece en ninguna pestaña de red y que resulta igual de cara: el navegador construye el DOM, ejecuta los efectos, registra los listeners, crea contextos WebGL y arranca bucles de animación. Todo eso ocurre aunque el elemento termine siendo invisible.

La distinción que resuelve el problema tiene tres niveles, y suele ser el punto de partida de casi cualquier conversación sobre rendimiento en el cliente. Una cosa es no mostrar, que consiste en esconder con CSS. Otra es no montar, es decir, que React nunca llegue a construir ese DOM ni a ejecutar sus efectos. Y la tercera es no cargar, o sea que el navegador no llegue a pedir ese archivo. Son tres ejes independientes: una solución para el primero no toca ninguno de los otros dos.

El error de siempre: esconder con CSS

El error más común consiste en resolver todo con una regla de media query. Es la solución más rápida de escribir y también la que menos rinde:

/* El componente se monta igual en movil, solo que tapado */
.lienzo-3d {
  display: none;
}

@media (min-width: 769px) {
  .lienzo-3d {
    display: block;
  }
}

Con este código, en un móvil el usuario no ve nada, pero el componente se renderizó por completo. Se ejecutó su useEffect, se registraron sus eventos, se reservó memoria para sus objetos y, si es algo gráfico, se creó el contexto WebGL. El navegador hizo todo el trabajo y después creó un elemento al que le puso display: none. Para un componente estático la diferencia es menor, pero para un sistema de partículas o una escena 3D es notable, porque un requestAnimationFrame que sigue corriendo con el nodo oculto consume batería igual. El trabajo se pagó entero para no mostrar nada.

La solución correcta no es esconder el resultado, es no generar el resultado.

No montar: decidir antes de renderizar

La forma adecuada es consultar el viewport antes de renderizar, y devolver la rama pesada directamente como null o detrás de un &&. El componente no existe para React, y todo lo que cuelga de él desaparece con él:

import { useState, useEffect } from "react";

function useEsMovil() {
  const [esMovil, setEsMovil] = useState(false);

  useEffect(() => {
    const consulta = window.matchMedia("(max-width: 768px)");
    const actualizar = (evento) => setEsMovil(evento.matches);
    actualizar({ matches: consulta.matches });

    consulta.addEventListener("change", actualizar);
    return () => consulta.removeEventListener("change", actualizar);
  }, []);

  return esMovil;
}

function Panel() {
  const esMovil = useEsMovil();

  return (
    <div className="panel">
      <Encabezado />
      {esMovil ? <VersionLigera /> : <LienzoPesado />}
    </div>
  );
}

El detalle que hace que esto funcione es que el if está en JavaScript, no en CSS. React nunca llega a llamar al componente de la rama pesada, así que nunca se construye su DOM, nunca corren sus efectos y nunca se registran sus listeners. Es el salto entre no verse y no existir, y conviene ser explícito con esa diferencia porque las dos cosas se parecen en la pantalla y no se parecen en la factura.

En este ejemplo la versión ligera sigue apareciendo, que suele ser lo deseable: el usuario no se queda sin la función, solo recibe una versión más barata. Cuando no hay una versión alternativa razonable, la rama directamente devuelve null:

{esMovil ? null : <LienzoPesado />}

No cargar: dónde está escrito el import

Aquí es donde aparece la trampa más interesante. Supongamos que el componente se carga de forma diferida, con lazy:

import { lazy, Suspense } from "react";

const LienzoPesado = lazy(() => import("./LienzoPesado.jsx"));

Esto parece correcto, y en un escritorio lo es: el archivo no se descarga hasta que el componente se monta. El problema aparece cuando alguien optimiza el arranque adelantando la descarga, y lo escribe a nivel de módulo, fuera del componente:

// Esto anula el lazy: se ejecuta al cargar el archivo, siempre
const chunk = import("./LienzoPesado.jsx");
const LienzoPesado = lazy(() => chunk);

La intención era buena: en un escritorio la escena se abre nada más entrar, así que conviene tener el archivo en vuelo mientras se pinta el resto. El problema es que un import() dinámico a nivel de módulo lo evalúa JavaScript al arrancar la página, antes de que ninguna decisión del programa pueda influir. Un móvil carga ese archivo sí o sí, y el componente queda sin montar. El archivo se descargó para nada.

La corrección es mover el import() dentro del envoltorio de carga diferida:

const LienzoPesado = lazy(() => import("./LienzoPesado.jsx"));

Con esto se conservan las dos cosas a la vez. El escritorio sigue sintiendo la descarga inmediata, porque la pantalla de bienvenida monta el componente nada más entrar. El móvil deja de pedirlo, porque nunca llega a montarse y la función nunca se invoca.

La lección general es que la carga diferida no es una propiedad del componente, sino del sitio exacto donde se escribe el import. Por eso conviene revisar dónde están los imports, no solo dónde están los JSX. Un componente puede estar perfectamente envuelto en lazy y seguir siendo una descarga obligatoria.

No cargar: las imágenes se deciden en el render

Con las imágenes la intuición falla, porque aquí el archivo pesa en bytes pero no viaja dentro del JavaScript. Cuando una imagen vive en la carpeta pública, el empaquetador no la incrusta en el bundle: escribe su URL, y los bytes viajan aparte, como un archivo independiente que el navegador pide cuando algo lo referencia. La consecuencia es que el peso total del directorio no dice nada sobre lo que se descarga. En un ejemplo con siete imágenes que suman casi 5 MB, un móvil no estaba bajándose 5 MB, sino únicamente la activa, de 1,7 MB. Las otras seis seguían en el disco, sin coste.

Y aquí está la parte que sorprende: para que no se descarguen no hace falta borrar nada. Basta con que el elemento que las referencia no exista:

function CarouselDeFotos({ activo }) {
  const [indice, setIndice] = useState(0);

  return (
    <div className="marco" style={{ backgroundImage: `url(${fotos[indice]})` }}>
      <BotonAnterior onClick={() => setIndice(indice - 1)} />
      <span>{anios[indice]}</span>
      <BotonSiguiente onClick={() => setIndice(indice + 1)} />
    </div>
  );
}

function Lateral({ esMovil }) {
  // Sin este bloque, el estilo nunca se aplica y la peticion nunca ocurre
  return (
    <div className="columna">
      {esMovil ? null : <CarouselDeFotos />}
      <SeccionContacto />
    </div>
  );
}

Mientras el <div> no se construya, la propiedad backgroundImage no llega a aplicarse, el navegador nunca ve una referencia y por tanto nunca inicia la descarga. Los archivos siguen ahí, copiados, sin que nadie los pida. Lo que evita la descarga no es una regla de CSS ni una opción de compilación, es que el nodo no exista.

El mismo razonamiento aplica a sprites y a iconos: un archivo de 50 KB solo se descarga si algún <use> lo referencia, y si el elemento que lo referencia no se monta, el archivo se queda en el disco sin coste. Por eso el test que verifica este comportamiento no debería comprobar que el elemento no aparece, sino que en el HTML final no existe ningún atributo de imagen de fondo. Eso es la prueba directa de que no hay nada capaz de provocar una petición de red. Un test que solo mira que el elemento no se pintó pasa igual aunque el archivo se siga descargando.

El criterio

La pregunta útil antes de decidir no es cuánto pesa el archivo, sino qué trabajo genuino se está haciendo aunque el usuario no lo vea. Si la respuesta es que se están creando nodos, registrando eventos o arrancando un contexto gráfico, hace falta una puerta en el render. Si solo es una imagen o un icono que el navegador descargaría por estar referenciado, basta con no renderizar y el peso desaparece solo, sin tocar la carga de archivos.

Y si además el componente se descarga de forma diferida, hay que verificar que el import esté realmente dentro de la fábrica. Un import dinámico mal colocado anula la estrategia entera, y no se nota en la pantalla: se nota en la pestaña de red.

Conviene añadir una segunda puerta, independiente del ancho de pantalla, para respetar la preferencia del sistema. Quien tenga "reducir movimiento" activado activada en su dispositivo debería obtener la versión ligera incluso en una pantalla grande, y quien tenga un móvil grande no debería cargar la escena pesada solo porque el ancho supere cierto número. Con ambos filtros, las dos ramas se complementan y ninguna depende de la otra para decidir.

© Arturo Juárez Monroy