Splitting por componente: lazy y Suspense
Dentro de una misma ruta hay código pesado que no se ve al entrar: modales, pestañas inactivas, editores enormes, mapas y gráficas. El splitting por componente difiere esos trozos con lazy y Suspense en React o Solid, y los carga cuando el usuario los provoca. La mecánica de lazy, el papel del límite de Suspense, la carga por interacción y cómo sobrevivir a un chunk que no llega.
La ruta te da el corte grueso; el componente te da el fino. Una misma pantalla puede pesar de más porque arrastra un editor de código de medio megabyte que solo se abre al pulsar un botón, un panel de gráficas oculto tras una pestaña, o un modal que quizá el usuario no abra nunca. Todo eso está en la ruta, pero no en la primera pintura. El splitting por componente difiere esos bultos concretos y los trae en el momento en que el usuario los reclama, envolviéndolos en un límite que sabe qué mostrar mientras llegan. Es cirugía dentro de la pantalla.
- Reconocer qué componentes merecen diferirse dentro de una ruta ya dividida.
- Dominar la mecánica de
lazyy el papel del límite deSuspense. - Cargar por interacción: adelantar el chunk al hover y montar al clic.
- Manejar el fallo de un chunk con un límite de error y una estrategia de reintento.
Diferir dentro de la ruta: el componente como unidad
Una vez dividida la aplicación por rutas, el candidato siguiente es todo lo pesado que vive dentro de una pantalla pero fuera de su primera pintura. El criterio es el mismo de siempre —diferir lo que no se necesita ahora— aplicado con lupa. Los sospechosos habituales se repiten en casi cualquier producto.
Modales y diálogos
Su contenido solo importa cuando se abren, y muchos no se abren nunca. Diferirlos es ganancia casi pura.
Pestañas inactivas
En un panel de pestañas, el usuario ve una a la vez. Carga la activa y difiere las demás hasta que se seleccionan.
Editores pesados
Monaco, CodeMirror o TipTap pesan cientos de KB. Rara vez se necesitan en el primer render y son el caso de manual.
Visualizaciones
Gráficas, mapas de Mapbox o lienzos de datos que a menudo viven bajo el pliegue y arrastran librerías enormes.
El patrón mental es preguntarse, componente a componente pesado: ¿esto se ve en el primer render, o aparece tras una acción? Si aparece tras una acción —abrir, seleccionar, desplazarse—, hay un punto de corte esperando.
Conviene, eso sí, no confundir granularidad con virtud. Diferir un componente diminuto —un botón, un icono— cuesta más de lo que ahorra: añades una petición y un límite de carga para recortar unos pocos KB que se habrían comprimido casi gratis dentro de un chunk mayor. El splitting por componente rinde cuando el bulto es grande y su aparición es incierta o tardía; para lo pequeño y omnipresente, déjalo viajar con la ruta. Ese equilibrio entre número de chunks y tamaño de cada uno es el tema del último nivel, pero conviene tenerlo en el radar desde ya.
lazy y Suspense: la mecánica
React expone la primitiva con lazy, que envuelve un import() dinámico y devuelve un componente normal que puedes renderizar. La única regla dura es que la llamada a lazy viva a nivel de módulo, nunca dentro del render: si la creas en cada render, generas un componente nuevo cada vez y React remonta y vuelve a descargar sin fin. Como el componente diferido no existe hasta que su chunk llega, hay que envolverlo en un límite de Suspense que declare qué enseñar mientras tanto.
import { lazy, Suspense, useState } from "react";
// A nivel de modulo: el chunk solo baja cuando el Editor se monta
const Editor = lazy(() => import("./Editor"));
function Pagina() {
const [abierto, setAbierto] = useState(false);
return (
<>
<button onClick={() => setAbierto(true)}>Abrir editor</button>
{abierto && (
<Suspense fallback={<Esqueleto alto={480} />}>
<Editor />
</Suspense>
)}
</>
);
}
El límite de Suspense es la pieza conceptual, no un mero detalle sintáctico. Marca la región de la interfaz que puede estar “todavía cargando” y define su estado de espera en un solo sitio, en lugar de esparcir banderas de carga por todo el componente. Solidjs copia el modelo casi al pie de la letra —su lazy y su Suspense se importan de solid-js y se usan igual—, mientras que Vue lo expresa con defineAsyncComponent y Svelte con un bloque await sobre el import(). El nombre cambia; la idea de un límite que gobierna la espera es universal.
// Solid: misma forma, el limite decide que mostrar mientras el chunk llega
import { lazy, Suspense } from "solid-js";
const GraficaPesada = lazy(() => import("./GraficaPesada"));
function Panel() {
return (
<Suspense fallback={<div class="esqueleto-grafica" />}>
<GraficaPesada />
</Suspense>
);
}
Dónde poner el límite
El límite de Suspense no tiene por qué envolver cada componente diferido por separado. Puedes agrupar varios bajo un mismo límite —y entonces esperan juntos, mostrando un solo fallback hasta que todos llegan— o darle a cada uno el suyo, para que aparezcan de forma independiente en cuanto su chunk esté listo. La elección es de diseño, no de mecánica: un límite ancho evita un mosaico de spinners parpadeando a destiempo; varios límites finos hacen que cada región se revele en cuanto puede. La regla es agrupar lo que conceptualmente llega junto y separar lo que el usuario percibe como piezas distintas.
Cargar por interacción y sobrevivir al fallo
Diferir hasta el montaje tiene un coste visible: cuando el usuario pulsa “abrir”, empieza entonces la descarga del chunk, y ve el fallback hasta que llega. El truco para borrar esa espera es adelantar la descarga a una señal más temprana que el clic. El ratón que se acerca al botón, o el foco que aterriza en él, son predicciones fiables de que el clic viene: dispara ahí el import() y, cuando el clic llegue, el chunk ya estará en caché.
// Adelantar la descarga al pasar el raton; montar al hacer clic
const precargarEditor = () => import("./Editor");
<button
onMouseEnter={precargarEditor}
onFocus={precargarEditor}
onClick={() => setAbierto(true)}
>
Abrir editor
</button>
Como el módulo se cachea tras el primer import(), llamarlo de nuevo al montar no cuesta una segunda descarga: el navegador devuelve el chunk ya resuelto. Esta idea —el import() es idempotente— es la que hace barato precargar de forma especulativa.
El hover y el foco no son las únicas señales. Para componentes que viven bajo el pliegue —una gráfica al fondo de un informe, una sección que solo se ve al desplazarse— la señal natural es la proximidad al viewport, que un IntersectionObserver detecta sin coste apreciable. Disparas el import() cuando el componente está a punto de entrar en pantalla, de modo que para cuando el usuario llega desplazándose, el chunk ya se está montando. Cada disparador —clic, hover, foco, intersección— es una apuesta distinta sobre cuándo el usuario reclamará ese código, y elegir el más temprano que sea fiable es lo que borra la latencia sin malgastar descargas.
El código que viaja en el bundle inicial no puede “no llegar”: si la página cargó, ese código está. Un chunk diferido, en cambio, se pide más tarde, y esa petición puede fallar por una red caída, un despliegue que rotó los nombres de archivo o un túnel que se cortó. Sin protección, el import() rechaza y la interfaz se rompe justo en la interacción del usuario. Todo componente diferido necesita, por encima de su Suspense, un límite de error que capture ese fallo y ofrezca reintentar.
// Un limite de error sobre el Suspense captura el chunk que no llega
<ErrorBoundary fallback={<Reintentar onRetry={recargar} />}>
<Suspense fallback={<Esqueleto alto={480} />}>
<Editor />
</Suspense>
</ErrorBoundary>
flowchart LR hover[El raton toca el boton] --> pre[import adelanta el chunk] pre --> listo[El chunk queda en cache] click[El usuario hace clic] --> listo listo --> montar[Monta al instante sin espera] style montar fill:#a6e3a1,color:#11111b
Antes de que existiera un límite declarativo de carga, diferir un componente obligaba a esparcir el estado de espera por todo el código imperativo: una bandera cargando, un if que decide si pintar el spinner o el contenido, un manejador que la baja cuando el módulo llega, y la misma coreografía repetida en cada punto de la interfaz que pudiera estar esperando algo. El resultado era frágil y ruidoso, porque la ausencia de un dato o de un módulo no era un concepto del sistema, sino un accidente que cada componente gestionaba a su manera. Suspense da la vuelta a la relación. En lugar de que cada componente pregunte “¿ya llegó mi código?” y reaccione, el componente simplemente se suspende —comunica hacia arriba “todavía no estoy listo”— y deja que un límite ancestro decida qué mostrar mientras tanto. La espera deja de ser una bandera local y se vuelve un estado estructural del árbol, gobernado en un punto y no en cien. Esto es más que comodidad: es lo que permite que el mismo mecanismo sirva para el código diferido, para los datos que aún no llegaron y para el renderizado en streaming desde el servidor, porque los tres son la misma pregunta —¿qué enseño mientras algo que necesito todavía no está?— y merecen una sola respuesta. El límite de carga también te obliga a diseñar la espera en lugar de sufrirla: al declarar el fallback estás decidiendo, a conciencia, qué ve el usuario en ese hueco de tiempo, y si reservas ahí las dimensiones correctas evitas de paso el salto de layout que estropea la percepción. Interiorizar Suspense no es aprender una API; es adoptar la idea de que “aún no disponible” es un estado legítimo de la interfaz, tan digno de diseño explícito como el estado final. El lazy loading por componente solo se vuelve elegante cuando dejas de tratar la ausencia como un error a parchear y empiezas a tratarla como una fase a componer.
- Elige un componente pesado tras una interacción —un modal, una pestaña, un editor— y confirma en el build que hoy viaja en el chunk de la ruta.
- Conviértelo con
lazyy envuélvelo en unSuspensecon un fallback que reserve sus dimensiones reales, no un spinner suelto. - Añade la precarga al
onMouseEntery alonFocusdel disparador, y comprueba en la pestaña de red que el chunk baja antes del clic. - Envuelve el conjunto en un límite de error, simula una red caída bloqueando la petición del chunk y verifica que aparece la opción de reintentar en vez de una pantalla rota.
- Coloca por error un componente visible en el primer render tras
lazyy observa el spinner y el salto de layout: entiende por qué diferir el camino crítico es un antipatrón.