wandres.dev
SUSPENSE · coordinar la carga

Suspense y lazy: code splitting con carga diferida

lazy convierte un import dinámico en un componente cuyo módulo se descarga bajo demanda, y el Suspense más cercano cubre esa espera como si fuera un recurso, porque en el fondo lo es: un createResource cuyo dato es un módulo. Así partes el bundle en chunks, aligeras la carga inicial y traes cada trozo de la aplicación solo cuando se necesita. Se cubre por qué el límite lo detecta igual que a un recurso, cómo preload calienta el chunk antes de renderizar, por qué el lazy debe declararse en el ámbito de módulo para conservar identidad estable, y el caso natural de partir la aplicación por rutas.

⏱ 16 min

Enviar toda la aplicación en un solo archivo obliga al usuario a descargar código que quizá nunca use antes de ver la primera pantalla. lazy rompe esa atadura: envuelve un import dinámico y devuelve un componente cuyo módulo se descarga solo cuando se va a renderizar. El empaquetador lo separa en un chunk propio, y mientras ese chunk viaja por la red el Suspense más cercano muestra su fallback —exactamente el mismo mecanismo que usó para los recursos, porque un componente diferido no es más que un recurso cuyo valor resulta ser un módulo—. Esta lección conecta el code splitting con el límite asíncrono que ya conoces.

🎯 Al terminar esta lección sabrás
  • Crear componentes diferidos con lazy sobre un import dinámico.
  • Entender por qué Suspense cubre la carga del chunk como si fuera un recurso.
  • Precargar con preload para calentar el chunk antes de renderizar.
  • Declarar el lazy en el ámbito de módulo y partir la aplicación por rutas.

lazy: un componente que llega tarde

lazy recibe una función que devuelve un import() dinámico y te da a cambio un componente normal, que puedes usar en el JSX como cualquier otro. La diferencia es invisible hasta que se renderiza: en ese momento se dispara el import, el empaquetador ya separó ese módulo en un chunk independiente, y el navegador lo descarga. El código de ese componente no viajó en el bundle inicial; llegó solo cuando hizo falta.

import { lazy, Suspense } from "solid-js";

const Panel = lazy(() => import("./Panel")); // ámbito de módulo, identidad estable

function App() {
  return (
    <Suspense fallback={<Esqueleto />}>
      <Panel />
    </Suspense>
  );
}

El módulo apuntado debe exponer el componente como export por defecto, que es lo que lazy espera resolver. Si tu componente es un export con nombre, adapta la promesa para exponerlo como default:

// Componente con nombre: reempaqueta el módulo para que lazy encuentre un default.
const Panel = lazy(() =>
  import("./widgets").then((m) => ({ default: m.Panel })),
);

El resultado es un bundle inicial más pequeño —solo lo imprescindible para pintar la primera vista— y el resto de la aplicación fragmentada en piezas que llegan a demanda. En una app con muchas rutas o vistas pesadas, este reparto es la diferencia entre un arranque instantáneo y uno que arrastra megas de JavaScript que nadie mirará todavía.

Código Cuándo llega al navegador
shell, layout, primera vista en el bundle inicial
componente lazy no montado nunca, hasta que se renderiza
componente lazy al renderizarse en su chunk, bajo demanda
chunk ya descargado instantáneo, desde la caché

Por qué Suspense lo cubre

Aquí está la elegancia del diseño: no hay una maquinaria especial para lazy. Un componente diferido es, por dentro, un recurso cuyo fetch es la descarga del módulo. Renderizarlo mientras el chunk está en vuelo es leer un recurso pendiente, y eso —como ya sabes— incrementa el contador del Suspense más cercano y activa su fallback. Cuando el módulo llega, el recurso resuelve, el contador baja y el componente real sustituye al fallback. El límite asíncrono que aprendiste para datos sirve, sin cambio alguno, para código.

flowchart TD
I[bundle inicial ligero] --> REN[se renderiza el componente lazy]
REN --> IMP[el import dinamico pide el chunk]
IMP --> SUS[Suspense pinta el fallback]
CHK[el chunk llega y se cachea] --> REV[Suspense revela el componente]
style SUS fill:#f9e2af,color:#11111b
style REV fill:#a6e3a1,color:#11111b

Esta unidad tiene una ventaja de composición notable: como lazy y los recursos de datos hablan el mismo idioma, un único Suspense puede cubrir a la vez la descarga del chunk y la carga de los datos que ese componente pida al montarse. El usuario ve un solo fallback mientras, por debajo, viajan en paralelo el código y sus datos, y ambos se revelan juntos.

// Un único Suspense cubre el chunk del componente y los datos que pide al montarse.
const Editor = lazy(() => import("./Editor"));

<Suspense fallback={<Esqueleto />}>
  <Editor documentoId={id()} /> {/* el chunk viaja mientras cargan sus datos */}
</Suspense>;

Y como el chunk se cachea, la primera renderización suspende pero las siguientes son instantáneas: el módulo ya está en memoria.

preload y dónde se declara

Un componente diferido expone un método preload que dispara la descarga del chunk sin renderizarlo. Es la herramienta para esconder la latencia: calientas el módulo ante la primera señal de que el usuario lo va a necesitar —pasar el ratón por un enlace, enfocar un botón, cruzar un umbral de scroll— de modo que, cuando de verdad navegue, el chunk ya esté (o casi) en caché y no haya fallback visible.

const Ajustes = lazy(() => import("./Ajustes"));

// Calienta el chunk antes de que el usuario abra el panel.
<button onMouseEnter={() => Ajustes.preload()} onClick={abrir}>
  Ajustes
</button>;

El otro punto crítico es dónde declaras el lazy: siempre en el ámbito de módulo, nunca dentro del cuerpo de un componente. Un lazy crea la identidad del componente diferido una vez; si lo defines dentro del render, cada ejecución fabricaría un componente nuevo, disparando un import distinto y remontando desde cero. Declararlo arriba, junto a los import estáticos, le da una identidad estable que se reutiliza en cada uso. La regla rima con la de los primitivos: lo que tiene ciclo de vida se crea una vez, en un sitio estable, no en el camino caliente del render.

El caso natural: partir por rutas

El uso más rentable de lazy es el routing: cada ruta carga su propio chunk, así que el usuario descarga solo el código de la pantalla que visita. Con @solidjs/router basta con que el componente de cada ruta sea un lazy y con que el layout tenga un Suspense que cubra el cambio de página mientras el chunk de la nueva ruta llega.

import { lazy } from "solid-js";
import { Router, Route } from "@solidjs/router";

const Inicio = lazy(() => import("./paginas/Inicio"));
const Ajustes = lazy(() => import("./paginas/Ajustes"));

<Router root={Layout}>
  <Route path="/" component={Inicio} />
  <Route path="/ajustes" component={Ajustes} />
</Router>;

El armazón —cabecera, navegación— viaja en el bundle inicial y aparece al instante; el cuerpo de cada ruta llega cosido después bajo el Suspense del layout. Combinado con preload sobre los enlaces del menú, la navegación se siente inmediata porque el chunk de destino se calentó al pasar el ratón. Este patrón —rutas diferidas más un límite en el layout— es la columna vertebral del rendimiento de arranque en cualquier SPA de Solid seria.

✂️

Chunk aparte

El import dinámico se separa del bundle inicial; el código llega solo cuando se renderiza.

♻️

Mismo Suspense

lazy es un recurso más: un único límite cubre a la vez el chunk y los datos del componente.

🔥

preload por ruta

Calienta el chunk de destino al pasar el ratón para que la navegación no muestre fallback.

💡
El chunk se descarga una vez, aunque el componente aparezca mil

La descarga del módulo ocurre en la primera vez que el componente diferido se renderiza; a partir de ahí, el resultado del import queda cacheado por el propio motor de módulos, así que montar y desmontar el componente cien veces no dispara cien peticiones, solo la primera. Por eso un lazy sobre una vista que se abre y cierra a menudo no penaliza: suspende una vez y luego es tan barato como un componente estático. Y por eso preload funciona: se limita a adelantar esa primera descarga a un momento en que aún no molesta, para que la renderización real la encuentre ya resuelta.

⚠️
Nunca declares un lazy dentro del render

Si escribes const C = lazy(() => import("./C")) en el cuerpo de un componente, cada render crea un C distinto. Como Solid identifica los componentes por referencia, ese nuevo C no es «el mismo que carga»: es otro, con su propio import y su propio ciclo, así que el subárbol se remonta y el fallback parpadea sin razón. Peor aún, pierdes el preload, porque no tienes una referencia estable a la que llamarlo antes de tiempo. Declara todos tus lazy en el ámbito de módulo, exactamente como los import normales; ahí su identidad es única y estable, y preload tiene algo fijo a lo que engancharse.

lazy es un createResource cuyo dato es un módulo

El salto que unifica todo este nivel es darse cuenta de que lazy no introduce ningún concepto nuevo: reutiliza el que ya tenías. Un createResource toma una promesa de datos y expone su ciclo —cargando, listo— al grafo reactivo, de modo que leerlo mientras carga suspende. lazy toma una promesa de módulo —el import dinámico devuelve exactamente eso, una promesa— y hace lo mismo: expone su ciclo al grafo, y renderizar el componente antes de que el módulo llegue es leer un recurso pendiente. Por eso el mismo Suspense, el mismo contador de lecturas, la misma coordinación de la lección anterior sirven sin tocar una línea para datos y para código a la vez. Esta unificación no es una casualidad de implementación, es la tesis de Solid sobre lo asíncrono: haya lo que haya al otro lado de una promesa —una fila de una base de datos, un JSON de una API, un chunk de JavaScript— se modela igual, como un recurso que el límite espera. Y de ahí brotan las buenas prácticas casi solas: declaras el lazy en el ámbito de módulo porque un recurso con ciclo de vida se crea una vez; lo precargas con preload porque siempre puedes calentar un fetch antes de necesitar su valor; lo cubres con el mismo Suspense que cubre sus datos porque para el contador de lecturas son la misma clase de espera. Aprender code splitting en Solid no es aprender una API aparte; es reconocer que el módulo era un recurso desde el principio.

⚔️ Parte el bundle y esconde la latencia
  1. Convierte un componente pesado en lazy con import dinámico y export por defecto; envuélvelo en un Suspense y confirma en las herramientas de red que su chunk se descarga aparte.
  2. Comprueba que la primera renderización activa el fallback y que, una vez cargado, montarlo de nuevo es instantáneo porque el módulo quedó cacheado.
  3. Añade preload en el onMouseEnter del enlace que lleva a él y verifica que, al hacer clic después, no aparece el fallback.
  4. Declara a propósito un lazy dentro del cuerpo de un componente, observa el remontado y el parpadeo en cada render, y arréglalo subiéndolo al ámbito de módulo.
  5. Monta dos rutas diferidas con un Suspense en el layout y comprueba que cada una descarga su propio chunk solo al visitarla.