Las dos lecciones anteriores han atacado el trabajo que React hace mientras el usuario usa la aplicación: renders que sobran, cálculos que se repiten, identidades que cambian sin motivo. Queda el otro problema, el que aparece antes de que el usuario pueda usar nada. Hoy, cuando alguien abre CicloUrbano para ver si queda una bicicleta en Plaza Mayor, el navegador descarga y ejecuta el código del panel de taller, el del detalle de estaciones con sus pestañas, el del formulario de reserva y la biblioteca de gráficas de ocupación, antes de pintar la primera tarjeta. Todo ese código es correcto, está bien memorizado y no se va a usar en esa visita. Ninguna técnica de memorización lo arregla, porque el problema no es cuántas veces se ejecuta algo: es cuánto pesa lo que se descarga. La solución se llama división de código, consiste en partir el paquete en fragmentos que se descargan cuando hacen falta, y en React se expresa con dos piezas —React.lazy y <Suspense>— sobre un mecanismo del propio JavaScript: el import() dinámico. En esta lección aprenderás a medir el paquete, a partirlo por ruta y por componente, a precargar para que la espera no se note, a diseñar la espera sin saltos visuales y a sobrevivir cuando un fragmento no llega.

Contenido

  1. El problema del paquete único
  2. Medir antes de partir: inspeccionar el paquete
  3. Qué es la división de código y qué son los fragmentos
  4. El import() dinámico como frontera automática
  5. React.lazy y <Suspense fallback>
  6. División por ruta: la que más rinde
  7. Por qué el catálogo no se divide
  8. División por componente
  9. Precarga: que la espera no se note
  10. Diseñar la espera: esqueletos en lugar de «Cargando…»
  11. Cuando el fragmento no llega
  12. Otras palancas de tamaño
  13. Suspense es mucho más que esto

  1. El problema del paquete único

Cuando ejecutas npm run build, Vite recorre el grafo de importaciones desde main.jsx, resuelve cada import estático y lo empaqueta todo junto. Un fichero, o unos pocos. El navegador tiene que descargarlo entero, analizarlo entero y ejecutarlo entero antes de que React pinte nada.

Estas son las dependencias reales de CicloUrbano después del módulo 7, con tamaños orientativos de código de aplicación ya minificado y comprimido con gzip:

Parte Sin comprimir Comprimido (gzip)
react + react-dom ~140 KB ~45 KB
react-router ~70 KB ~22 KB
@reduxjs/toolkit + react-redux ~90 KB ~28 KB
@tanstack/react-query ~110 KB ~35 KB
Código propio de CicloUrbano ~180 KB ~45 KB
Biblioteca de gráficas de ocupación ~320 KB ~95 KB
Total ~910 KB ~270 KB

Y ahora la parte incómoda: de esos 270 KB comprimidos, la ruta de entrada usa aproximadamente la mitad. Las gráficas solo aparecen en el detalle de estación, PaginaTaller solo la ve un operario, y PaginaNuevaReserva solo se abre al reservar.

El coste no es solo la descarga. Analizar y ejecutar JavaScript es trabajo de CPU, y en un móvil de gama media va entre 3 y 5 veces más lento que en tu portátil:

flowchart LR
    A["Descargar<br/>270 KB en 4G<br/>~1,4 s"] --> B["Analizar y compilar<br/>910 KB sin comprimir<br/>~0,6 s en movil"]
    B --> C["Ejecutar<br/>modulos + main.jsx<br/>~0,3 s"]
    C --> D["Primer render<br/>+ peticion a la API"]
    D --> E["Catalogo visible<br/>~3,5 s"]

Tres segundos y medio hasta ver una bicicleta, la mitad de ellos gastados en código que esa visita no va a usar. Ese es el problema exacto que esta lección resuelve.

  1. Medir antes de partir: inspeccionar el paquete

Igual que en todo el módulo: primero se mide. Vite ya da la primera medición sin instalar nada.

npm run build
vite v6.0.5 building for production...
✓ 412 modules transformed.
dist/index.html                     0.48 kB │ gzip:   0.31 kB
dist/assets/index-B4nQ8xJ2.css     18.24 kB │ gzip:   4.02 kB
dist/assets/index-DvK2mR7p.js     908.77 kB │ gzip: 271.35 kB

(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application

Ese aviso de Rollup no es decorativo: es exactamente el consejo de esta lección. Pero la salida de Vite dice cuánto pesa, no qué pesa. Para eso hace falta un mapa del contenido.

npm install --save-dev rollup-plugin-visualizer
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({
      filename: 'dist/estadisticas.html',
      gzipSize: true,        // muestra el tamaño comprimido, que es el que importa
      brotliSize: true,
      open: false            // ponlo en true para abrirlo automáticamente
    })
  ]
});

Tras npm run build, abre dist/estadisticas.html: un mapa de árbol donde cada rectángulo es un módulo y su superficie es su peso. Lo que hay que buscar, por orden:

Señal en el mapa Qué significa Acción
Un rectángulo enorme de una biblioteca Una dependencia pesada que quizá no se usa siempre Cargarla bajo demanda (apartado 8)
Todo tu código en un solo bloque No hay ninguna división Dividir por ruta (apartado 6)
Una biblioteca de la que usas una función Importación no selectiva o biblioteca sin tree-shaking Revisar la importación (apartado 12)
Datos .json grandes empaquetados Datos que deberían pedirse, no incrustarse import() dinámico o petición
moment, lodash completos, iconos enteros Los sospechosos habituales Sustituir o importar selectivamente

Anota la cifra de partida —271 KB comprimidos— porque el apartado 6 la compara con la de después. Sin ese antes y después, esto no es optimización.

  1. Qué es la división de código y qué son los fragmentos

Dividir el código (code splitting) es partir el paquete único en varios ficheros, llamados fragmentos (chunks), de modo que el navegador descargue solo los que necesita para lo que el usuario está haciendo.

La división tiene dos niveles, y conviene distinguirlos:

  • División automática por dependencias comunes. Vite ya separa node_modules en un fragmento de proveedores (vendor) para que el código de bibliotecas, que cambia poco, se cachee mejor entre despliegues. Esto ocurre solo, y no afecta a cuánto se descarga en la primera visita.
  • División por puntos de carga, que es la de esta lección: tú decides qué partes de tu aplicación pueden esperar y las marcas como tales.

Lo interesante es cómo se marcan: no hay ninguna configuración. La frontera entre fragmentos la crea una sintaxis del propio JavaScript.

  1. El import() dinámico como frontera automática

// Importación estática: se resuelve al construir, entra en el paquete principal
import { generarInforme } from './utilidades/informes.js';

// Importación dinámica: devuelve una promesa, y crea un FRAGMENTO NUEVO
const { generarInforme } = await import('./utilidades/informes.js');

Diferencias esenciales:

import estático import() dinámico
Cuándo se resuelve Al construir el paquete En tiempo de ejecución
Qué devuelve Enlaces (bindings) Una promesa del objeto de módulo
Dónde puede aparecer Solo en el nivel superior del módulo En cualquier sitio: dentro de una función, de un if
Efecto en el empaquetado Se incorpora al fragmento actual Crea un fragmento separado
Puede llevar variables No Sí, con limitaciones (Vite necesita pistas estáticas en la ruta)

Y el efecto en el grafo de módulos:

flowchart TD
    subgraph ANTES["Un solo fragmento"]
        M1["main.jsx"] --> R1["rutas.jsx"]
        R1 --> P1["PaginaCatalogo"]
        R1 --> P2["PaginaTaller"]
        R1 --> P3["PaginaDetalleEstacion"]
        P3 --> G1["Biblioteca de graficas<br/>95 KB"]
        R1 --> P4["PaginaNuevaReserva"]
        M1 --> V1["react, router, redux, query"]
    end
flowchart TD
    subgraph DESPUES["Fragmento inicial + fragmentos bajo demanda"]
        M2["main.jsx"] --> R2["rutas.jsx"]
        R2 --> P5["PaginaCatalogo<br/>ruta de entrada: estatica"]
        M2 --> V2["react, router, redux, query"]
        R2 -.->|"import() al navegar"| C1["chunk: PaginaTaller"]
        R2 -.->|"import() al navegar"| C2["chunk: PaginaDetalleEstacion"]
        C2 -.->|"import() al abrir pestana"| C3["chunk: graficas 95 KB"]
        R2 -.->|"import() al navegar"| C4["chunk: PaginaNuevaReserva"]
    end

Las líneas discontinuas son peticiones de red que ocurren cuando el usuario llega ahí, no antes. Y no hace falta configurar nada: escribir import(...) es la instrucción completa.

  1. React.lazy y <Suspense fallback>

import() devuelve una promesa, y JSX no sabe qué hacer con una promesa. React.lazy es el puente.

import { lazy, Suspense } from 'react';

const PaginaTaller = lazy(() => import('./paginas/PaginaTaller.jsx'));

<Suspense fallback={<IndicadorDeCarga mensaje="Cargando taller…" />}>
  <PaginaTaller />
</Suspense>

Firma y exigencias de lazy:

  • Recibe una función que devuelve una promesa de un módulo. No la promesa directamente: la función permite a React llamarla en el momento adecuado.
  • El módulo debe exponer el componente como exportación por defecto. Es lo que React lee de la promesa resuelta. La convención de CicloUrbano (export default para componentes) encaja sin cambios.
  • lazy() debe llamarse fuera de cualquier componente, en el ámbito del módulo. Si se llama dentro, cada render crearía un componente perezoso nuevo y desmontaría el anterior, con lo que la pantalla parpadearía sin fin.

Si un módulo usara exportación nombrada, la adaptación es una línea:

// El módulo exporta { PanelGraficas }, no un default
const PanelGraficas = lazy(() =>
  import('./componentes/PanelGraficas.jsx').then((modulo) => ({ default: modulo.PanelGraficas }))
);

Comportamiento la primera vez frente a las siguientes:

Momento Qué ocurre
Primera vez que se renderiza React llama a la función, se dispara la petición del fragmento, el componente «suspende» y Suspense muestra el fallback
Cuando la promesa se resuelve React renderiza el componente real en el sitio del fallback
Siguientes veces El módulo ya está en memoria: no hay fallback ni parpadeo. Es instantáneo
Si la promesa se rechaza El error sube hasta el límite de error más cercano (apartado 11)

<Suspense> tiene una sola prop relevante aquí, fallback, y una regla de colocación: debe estar por encima del componente perezoso en el árbol, y su posición decide qué parte de la interfaz se sustituye por el indicador de carga. Un Suspense en la raíz sustituye toda la aplicación; uno alrededor del <Outlet /> sustituye solo el contenido de la página y deja la Cabecera y el PieDePagina intactos. Esa diferencia se nota muchísimo.

  1. División por ruta: la que más rinde

La división por ruta es la que mejor relación beneficio/esfuerzo tiene, y el motivo es evidente cuando se dice en voz alta: el usuario está en una ruta a la vez. Todo lo que pertenece a otras rutas es, por definición, código que ahora no hace falta.

Este es el mapa de rutas de CicloUrbano con la división aplicada:

// src/rutas.jsx
import { lazy, Suspense } from 'react';
import { createBrowserRouter } from 'react-router';

import Diseno from './componentes/Diseno.jsx';
import PaginaCatalogo from './paginas/PaginaCatalogo.jsx';        // 1) ruta de entrada: estática
import PaginaAcceso from './paginas/PaginaAcceso.jsx';            // 2) pequeña y muy usada
import PaginaNoEncontrada from './paginas/PaginaNoEncontrada.jsx';
import PaginaSinPermisos from './paginas/PaginaSinPermisos.jsx';
import PaginaErrorRuta from './paginas/PaginaErrorRuta.jsx';      // 3) NUNCA perezosa
import RutaProtegida from './componentes/RutaProtegida.jsx';
import RequiereRol from './componentes/RequiereRol.jsx';
import EsqueletoPagina from './componentes/EsqueletoPagina.jsx';
import { estaciones } from './datos/dominio.js';

// 4) Fragmentos bajo demanda: uno por cada import() dinámico
const PaginaFichaBicicleta = lazy(() => import('./paginas/PaginaFichaBicicleta.jsx'));
const PaginaEstaciones     = lazy(() => import('./paginas/PaginaEstaciones.jsx'));
const PaginaDetalleEstacion = lazy(() => import('./paginas/PaginaDetalleEstacion.jsx'));
const PestanaFlota         = lazy(() => import('./paginas/PestanaFlota.jsx'));
const PestanaIncidencias   = lazy(() => import('./paginas/PestanaIncidencias.jsx'));
const PaginaReservas       = lazy(() => import('./paginas/PaginaReservas.jsx'));
const PaginaNuevaReserva   = lazy(() => import('./paginas/PaginaNuevaReserva.jsx'));
const PaginaTaller         = lazy(() => import('./paginas/PaginaTaller.jsx'));

// 5) Envoltura reutilizable: cada página perezosa con su propio Suspense
function Perezosa({ children }) {
  return <Suspense fallback={<EsqueletoPagina />}>{children}</Suspense>;
}

export const router = createBrowserRouter([
  {
    path: '/',
    element: <Diseno />,
    errorElement: <PaginaErrorRuta />,          // 6) captura también los fallos de carga
    handle: { miga: 'Inicio' },
    children: [
      { index: true, element: <PaginaCatalogo />, handle: { miga: 'Catálogo' } },

      {
        path: 'bicicletas/:bicicletaId',
        element: <Perezosa><PaginaFichaBicicleta /></Perezosa>,
        handle: { miga: 'Ficha de bicicleta' }
      },

      {
        path: 'estaciones',
        handle: { miga: 'Estaciones' },
        children: [
          { index: true, element: <Perezosa><PaginaEstaciones /></Perezosa> },
          {
            path: ':estacionId',
            element: <Perezosa><PaginaDetalleEstacion /></Perezosa>,
            handle: {
              miga: (params) =>
                estaciones.find((est) => est.id === params.estacionId)?.nombre ?? 'Estación'
            },
            children: [
              { index: true, element: <Perezosa><PestanaFlota /></Perezosa> },
              { path: 'incidencias', element: <Perezosa><PestanaIncidencias /></Perezosa> }
            ]
          }
        ]
      },

      {
        path: 'reservas',
        element: <MarcoReservas />,
        handle: { miga: 'Reservas' },
        children: [
          { index: true, element: <Perezosa><PaginaReservas /></Perezosa> },
          {
            path: 'nueva',
            element: <Perezosa><PaginaNuevaReserva /></Perezosa>,
            handle: { miga: 'Nueva reserva' }
          }
        ]
      },

      { path: 'acceso', element: <PaginaAcceso />, handle: { miga: 'Acceso' } },

      {
        element: <RutaProtegida />,
        children: [
          {
            element: <RequiereRol rolesPermitidos={['operario']} />,
            children: [
              {
                path: 'taller',
                element: <Perezosa><PaginaTaller /></Perezosa>,
                handle: { miga: 'Taller' }
              }
            ]
          }
        ]
      },

      { path: '*', element: <PaginaNoEncontrada /> }
    ]
  }
]);

Los seis puntos marcados:

  1. PaginaCatalogo se queda estática. Es la ruta índice: dividirla sería añadir una petición de red extra justo en el camino crítico. Se explica en el apartado 7.
  2. PaginaAcceso también. Es pequeña, es el destino de toda redirección de RutaProtegida, y hacerla perezosa introduciría un parpadeo en un momento sensible.
  3. PaginaErrorRuta nunca debe ser perezosa. Si el fallo que hay que mostrar es precisamente un fallo de red, cargar el fragmento del error también fallaría. Los componentes de error se empaquetan siempre en el fragmento principal.
  4. Un import() por página = un fragmento por página. Sin configuración adicional.
  5. La envoltura Perezosa evita repetir <Suspense fallback={...}> en nueve sitios y garantiza la misma experiencia de carga en todos. Colocar el Suspense dentro de cada ruta, y no alrededor del <Outlet /> en Diseno, hace que solo se sustituya el contenido de la página: la cabecera, las migas de pan y el pie no parpadean.
  6. El errorElement ya existente captura los errores lanzados durante el render de las rutas hijas, incluidos los rechazos de import(). Se detalla en el apartado 11.

Y el resultado, que es el punto entero de la lección:

dist/assets/index-C8k2Nx9L.js         412.30 kB │ gzip: 128.44 kB   ← inicial
dist/assets/PaginaTaller-Bq7Wm3.js     38.12 kB │ gzip:  11.20 kB
dist/assets/PaginaDetalleEstacion-D2.js 44.90 kB │ gzip:  13.05 kB
dist/assets/PestanaFlota-Kl9x2.js      12.44 kB │ gzip:   3.90 kB
dist/assets/PestanaIncidencias-Mn4.js  14.02 kB │ gzip:   4.31 kB
dist/assets/PaginaNuevaReserva-Rp8.js  31.60 kB │ gzip:   9.44 kB
dist/assets/PaginaReservas-Ty3q.js     22.18 kB │ gzip:   6.72 kB
dist/assets/PaginaFichaBicicleta-Zx.js 18.90 kB │ gzip:   5.88 kB
dist/assets/PaginaEstaciones-Ab5.js    16.30 kB │ gzip:   5.02 kB
dist/assets/graficas-Qw7e1.js         310.80 kB │ gzip:  94.60 kB
Antes Después
JavaScript inicial (gzip) 271 KB 128 KB
Reducción −53 %
Fragmentos 1 10
Coste de navegar al taller 0 (ya descargado) Una petición de ~11 KB
Presupuesto de 08-01 (< 200 KB) ❌ Incumplido ✅ Cumplido

  1. Por qué el catálogo no se divide

Es tentador aplicar lazy a todo. Sería un error, y entender por qué distingue a quien optimiza de quien copia recetas.

La ruta de entrada es la que el usuario ve primero. Si la haces perezosa, la secuencia se alarga en lugar de acortarse:

flowchart TD
    subgraph A["Ruta de entrada ESTATICA (correcto)"]
        A1["Descargar index.js"] --> A2["Ejecutar"] --> A3["Pintar catalogo"]
    end
    subgraph B["Ruta de entrada PEREZOSA (error)"]
        B1["Descargar index.js"] --> B2["Ejecutar"] --> B3["Pintar el fallback"]
        B3 --> B4["Descargar chunk del catalogo<br/>peticion EXTRA en cadena"]
        B4 --> B5["Ejecutar"] --> B6["Pintar catalogo"]
    end

Se añade un viaje de ida y vuelta a la red en serie, justo en el camino crítico, y encima se empeora el LCP mostrando primero un esqueleto. El código ahorrado es cero, porque ese código se necesita igualmente.

La regla general para decidir:

Divide No dividas
Rutas a las que se llega navegando La ruta de entrada
Pantallas de un rol minoritario (PaginaTaller) Componentes del marco: Diseno, Cabecera, PieDePagina
Modales y diálogos que hay que abrir Componentes de error: PaginaErrorRuta, LimiteDeError
Bibliotecas pesadas de uso puntual (gráficas, editores, mapas) Componentes pequeños: partir 3 KB no compensa la petición
Pestañas que no son la pestaña índice Nada que se necesite en el primer pintado

Y una regla de tamaño: por debajo de unos 20 KB sin comprimir, un fragmento propio rara vez compensa. Una petición HTTP tiene su propio coste de latencia, y diez fragmentos minúsculos son peores que uno mediano.

  1. División por componente

No todo lo divisible es una ruta. Dentro de una misma pantalla hay partes que solo existen si el usuario hace algo.

Caso 1: DialogoReserva. Vive en la ficha de bicicleta pero solo se monta al pulsar «Reservar». Arrastra el FormularioReserva, su validación y useBloquearSalida.

// src/paginas/PaginaFichaBicicleta.jsx
import { lazy, Suspense, useState } from 'react';
import { useParams } from 'react-router';
import Panel from '../componentes/Panel.jsx';
import IndicadorDeCarga from '../componentes/IndicadorDeCarga.jsx';
import { useBicicleta } from '../consultas/consultasBicicletas.js';

const DialogoReserva = lazy(() => import('../componentes/DialogoReserva.jsx'));

function PaginaFichaBicicleta() {
  const { bicicletaId } = useParams();
  const { data: bicicleta, isPending } = useBicicleta(bicicletaId);
  const [dialogoAbierto, setDialogoAbierto] = useState(false);

  if (isPending) return <IndicadorDeCarga mensaje="Cargando bicicleta…" />;

  return (
    <Panel titulo={bicicleta.modelo}>
      {/* … la ficha … */}
      <button
        type="button"
        onClick={() => setDialogoAbierto(true)}
        // Precarga al pasar el ratón: para cuando se pulse, el fragmento ya está
        onMouseEnter={() => import('../componentes/DialogoReserva.jsx')}
        onFocus={() => import('../componentes/DialogoReserva.jsx')}
        disabled={bicicleta.estado !== 'disponible'}
      >
        Reservar
      </button>

      {/* El import() NO se dispara mientras dialogoAbierto sea false */}
      {dialogoAbierto && (
        <Suspense fallback={<IndicadorDeCarga mensaje="Preparando la reserva…" />}>
          <DialogoReserva
            bicicleta={bicicleta}
            alCerrar={() => setDialogoAbierto(false)}
          />
        </Suspense>
      )}
    </Panel>
  );
}

export default PaginaFichaBicicleta;

El detalle que hace que esto funcione: lazy no descarga nada hasta que el componente se renderiza de verdad. Con el renderizado condicional {dialogoAbierto && ...}, la petición se dispara en el momento de abrir, no al montar la página.

Caso 2: la biblioteca de gráficas. Es el fragmento de 95 KB comprimidos, y solo se usa en la pestaña de flota de una estación, dentro de un desplegable de ocupación semanal.

// src/componentes/GraficaOcupacion.jsx
// Este módulo importa la biblioteca de forma ESTÁTICA: es su propio fragmento
import { GraficoBarras, Eje, Barra, Rejilla } from 'una-biblioteca-de-graficas';

function GraficaOcupacion({ datos }) {
  return (
    <GraficoBarras datos={datos} ancho={640} alto={280}>
      <Rejilla />
      <Eje eje="x" clave="dia" />
      <Eje eje="y" />
      <Barra clave="alquileres" color="var(--color-marca)" />
    </GraficoBarras>
  );
}

export default GraficaOcupacion;
// src/paginas/PestanaFlota.jsx
import { lazy, Suspense, useState } from 'react';

// Al ser el único que importa la biblioteca, el fragmento la incluye entera
const GraficaOcupacion = lazy(() => import('../componentes/GraficaOcupacion.jsx'));

function PestanaFlota() {
  const [verGrafica, setVerGrafica] = useState(false);

  return (
    <>
      <ListaBicicletas bicicletas={bicicletas} />

      <button type="button" onClick={() => setVerGrafica((v) => !v)}>
        {verGrafica ? 'Ocultar' : 'Ver'} ocupación semanal
      </button>

      {verGrafica && (
        <Suspense fallback={<EsqueletoGrafica />}>
          <GraficaOcupacion datos={datosSemana} />
        </Suspense>
      )}
    </>
  );
}

La técnica clave se llama aislamiento por módulo: se crea un componente envoltorio (GraficaOcupacion) que es el único punto del proyecto que importa la biblioteca pesada, y se carga ese envoltorio de forma perezosa. Así el empaquetador puede meter la biblioteca entera en un fragmento aparte. Si otro módulo la importara estáticamente, volvería al paquete principal y todo el esfuerzo se perdería.

Comprobación práctica: tras npm run build, busca en dist/assets/ un fragmento con el peso de la biblioteca. Si no aparece y el paquete principal sigue gordo, alguien la está importando estáticamente en otro sitio.

  1. Precarga: que la espera no se note

La división de código cambia «esperar al principio» por «esperar al navegar». La precarga elimina la segunda espera aprovechando el tiempo en que el usuario se está decidiendo: los cientos de milisegundos entre que el ratón llega a un enlace y el dedo pulsa.

// src/componentes/EnlacePrecargado.jsx
import { Link } from 'react-router';

function EnlacePrecargado({ to, cargar, children, ...resto }) {
  let precargado = false;

  function precargar() {
    if (precargado) return;      // una sola vez por instancia
    precargado = true;
    cargar();                    // dispara el import() y su fragmento
  }

  return (
    <Link
      to={to}
      onMouseEnter={precargar}
      onFocus={precargar}          // teclado: mismo comportamiento (03-06)
      onTouchStart={precargar}     // móvil: no hay hover, pero sí un instante antes del tap
      {...resto}
    >
      {children}
    </Link>
  );
}

export default EnlacePrecargado;
// src/componentes/Cabecera.jsx (fragmento)
<nav>
  <EnlacePrecargado to="/" cargar={() => {}}>Catálogo</EnlacePrecargado>

  <EnlacePrecargado
    to="/estaciones"
    cargar={() => import('../paginas/PaginaEstaciones.jsx')}
  >
    Estaciones
  </EnlacePrecargado>

  <EnlacePrecargado
    to="/reservas"
    cargar={() => import('../paginas/PaginaReservas.jsx')}
  >
    Mis reservas
  </EnlacePrecargado>

  {esOperario && (
    <EnlacePrecargado
      to="/taller"
      cargar={() => import('../paginas/PaginaTaller.jsx')}
    >
      Taller
    </EnlacePrecargado>
  )}
</nav>

Por qué funciona tan bien: el navegador cachea el módulo, así que cuando lazy pida el mismo import() al navegar, la promesa se resuelve de inmediato y Suspense no llega ni a mostrar el fallback. El usuario percibe una navegación instantánea aunque el código se haya descargado hace medio segundo.

Estrategias de precarga, comparadas:

Estrategia Cuándo dispara Riesgo Recomendación
Al pasar el ratón / enfocar El usuario apunta al enlace Mínimo: la intención es alta ✅ Por defecto
Al tocar (onTouchStart) Antes del click en móvil Mínimo: gana ~80 ms ✅ Complementaria
Tras el pintado inicial (requestIdleCallback) Cuando el hilo está libre Consume datos que quizá no se usen Solo para 1–2 rutas muy probables
Al aparecer en pantalla (IntersectionObserver) El enlace entra en la vista Puede precargar demasiado Para listas de enlaces
Todo al arrancar Inmediatamente Anula la división de código ❌ Nunca

Un ejemplo de la tercera, para la ruta que casi todo el mundo visita después del catálogo:

// src/componentes/Diseno.jsx (fragmento)
useEffect(() => {
  const id = requestIdleCallback?.(() => {
    import('../paginas/PaginaFichaBicicleta.jsx');   // la siguiente ruta más probable
  });
  return () => cancelIdleCallback?.(id);
}, []);

  1. Diseñar la espera: esqueletos en lugar de «Cargando…»

Un fallback mal diseñado empeora la percepción de rendimiento aunque los milisegundos sean idénticos. Dos motivos, y los dos son medibles:

  • Salto de maquetación (CLS). Un <p>Cargando…</p> ocupa 20 píxeles de alto; la página que llega ocupa 900. Al sustituirse, todo salta. Si el usuario ya estaba pulsando algo, pulsa donde no quería.
  • Ruptura del contexto. Un texto centrado en una pantalla vacía comunica «has salido de donde estabas». Un esqueleto con la forma de lo que viene comunica «esto ya está llegando».
// src/componentes/EsqueletoPagina.jsx
import estilos from './EsqueletoPagina.module.css';

function EsqueletoPagina({ filas = 6 }) {
  return (
    <div className={estilos.esqueleto} role="status" aria-busy="true" aria-live="polite">
      <span className={estilos.textoAccesible}>Cargando contenido…</span>

      <div className={estilos.titulo} />
      <div className={estilos.subtitulo} />

      <div className={estilos.rejilla}>
        {Array.from({ length: filas }, (_, indice) => (
          <div key={indice} className={estilos.tarjeta} />
        ))}
      </div>
    </div>
  );
}

export default EsqueletoPagina;
/* src/componentes/EsqueletoPagina.module.css */
.esqueleto {
  min-height: 70vh;              /* reserva la altura: evita el salto (CLS) */
  padding: 1rem;
}

.titulo,
.subtitulo,
.tarjeta {
  background: linear-gradient(
    90deg,
    var(--color-borde) 25%,
    var(--color-superficie) 50%,
    var(--color-borde) 75%
  );
  background-size: 200% 100%;
  animation: brillo 1.4s ease-in-out infinite;
  border-radius: 8px;
}

.titulo    { height: 2rem;   width: 40%; margin-bottom: 0.75rem; }
.subtitulo { height: 1rem;   width: 65%; margin-bottom: 1.5rem; }

.rejilla {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 1rem;
}

.tarjeta { height: 120px; }      /* la misma altura que TarjetaBicicleta */

@keyframes brillo {
  from { background-position: 200% 0; }
  to   { background-position: -200% 0; }
}

/* Accesibilidad: respeta la preferencia de movimiento reducido (03-06) */
@media (prefers-reduced-motion: reduce) {
  .titulo, .subtitulo, .tarjeta { animation: none; }
}

.textoAccesible {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

Las decisiones de diseño y su porqué:

Decisión Motivo
min-height: 70vh y alturas iguales a las reales El contenido real ocupa lo mismo: cero salto de maquetación
role="status" + aria-busy + aria-live="polite" Un lector de pantalla anuncia la carga; sin esto, la espera es invisible (03-06)
Texto accesible oculto Los rectángulos no dicen nada a quien no ve la pantalla
prefers-reduced-motion La animación de brillo puede molestar o marear
Misma rejilla que el contenido real La transición se percibe como un relleno, no como un cambio de pantalla

Y dos advertencias que evitan el efecto contrario al buscado:

  • Un fallback que aparece y desaparece en 80 ms es peor que ninguno: se percibe como un parpadeo. Si el fragmento es pequeño y hay precarga, valora retrasar la aparición del esqueleto (mostrarlo solo si la espera supera ~200 ms) o confiar en la precarga.
  • No pongas un Suspense en la raíz para las rutas. Sustituiría la cabecera y el pie, y cada navegación parecería una recarga completa de la página.

  1. Cuando el fragmento no llega

Este es el apartado que separa una división de código de juguete de una de producción. Un import() puede fallar, y falla más de lo que parece:

Causa Frecuencia Qué ve el usuario si no lo gestionas
Red caída o túnel Alta en móvil La pantalla se queda con el esqueleto para siempre
Despliegue nuevo con pestaña vieja Muy alta en producción Error 404: el fragmento con ese hash ya no existe
Caché intermedia corrupta Baja Error al analizar el módulo
Bloqueador de anuncios agresivo Baja Petición cancelada

El segundo caso merece explicación porque sorprende a todo el mundo: Vite pone un hash del contenido en el nombre de cada fragmento (PaginaTaller-Bq7Wm3.js). Si despliegas una versión nueva mientras alguien tiene la aplicación abierta, esa pestaña sigue ejecutando el rutas.jsx viejo, que pide un fragmento con un hash que ya no está en el servidor. La aplicación funciona perfectamente… hasta que el usuario navega a una ruta que aún no había visitado.

La solución combina Suspense (para la espera) con LimiteDeError (para el fallo), que es exactamente la pieza construida en 04-05:

// src/componentes/LimiteDeCarga.jsx
import { Suspense } from 'react';
import LimiteDeError from './LimiteDeError.jsx';
import EsqueletoPagina from './EsqueletoPagina.jsx';
import estilos from './LimiteDeCarga.module.css';

function FalloDeCarga({ error, alReintentar }) {
  // Un fragmento que no llega produce un error de importación dinámica
  const esFalloDeFragmento =
    /Failed to fetch dynamically imported module|Importing a module script failed/i.test(
      error?.message ?? ''
    );

  return (
    <div className={estilos.fallo} role="alert">
      <h2>No se ha podido cargar esta sección</h2>

      {esFalloDeFragmento ? (
        <p>
          Puede que haya una versión nueva de CicloUrbano disponible, o que la
          conexión se haya interrumpido. Vuelve a cargar la página para continuar.
        </p>
      ) : (
        <p>Ha ocurrido un error inesperado al abrir esta sección.</p>
      )}

      <button type="button" onClick={() => window.location.reload()}>
        Recargar la página
      </button>
      <button type="button" onClick={alReintentar}>
        Reintentar sin recargar
      </button>
    </div>
  );
}

function LimiteDeCarga({ children, fallback = <EsqueletoPagina /> }) {
  return (
    // 1) El límite de error va POR FUERA: solo así captura el rechazo de la promesa
    <LimiteDeError respaldo={(error, reintentar) => (
      <FalloDeCarga error={error} alReintentar={reintentar} />
    )}>
      {/* 2) Suspense por dentro: se ocupa de la espera, no del fallo */}
      <Suspense fallback={fallback}>{children}</Suspense>
    </LimiteDeError>
  );
}

export default LimiteDeCarga;

El orden de anidamiento no es negociable:

flowchart TD
    A["LimiteDeError<br/>captura el rechazo del import()"] --> B["Suspense<br/>muestra el esqueleto mientras carga"]
    B --> C["Componente perezoso"]
    C -->|"promesa pendiente"| D["Se ve el esqueleto"]
    C -->|"promesa resuelta"| E["Se ve la pagina"]
    C -->|"promesa rechazada"| F["El error sube hasta LimiteDeError<br/>se ve el fallo con boton de recarga"]

Si lo pusieras al revés —Suspense por fuera y LimiteDeError por dentro—, el límite se destruiría al suspenderse el árbol y no habría nadie que capturase el fallo.

En rutas.jsx basta con sustituir la envoltura Perezosa por LimiteDeCarga:

function Perezosa({ children }) {
  return <LimiteDeCarga>{children}</LimiteDeCarga>;
}

Con esto, el errorElement: <PaginaErrorRuta /> de la ruta raíz sigue siendo la última red de seguridad, y LimiteDeCarga ofrece un mensaje específico y accionable —recargar— en el sitio donde ocurre el fallo, sin tirar abajo la navegación entera. Recuerda por qué existen los dos: errorElement captura los errores de las rutas y de sus cargadores; LimiteDeError captura los del render de los componentes. Un fallo de import() ocurre durante el render, así que le corresponde el segundo.

Por qué «recargar» es la acción correcta. En el caso más frecuente —despliegue nuevo— reintentar el mismo import() volvería a pedir el fragmento antiguo y volvería a fallar. Recargar la página trae el index.html nuevo, con las referencias correctas, y el problema desaparece. Por eso el botón principal recarga y el secundario reintenta.

  1. Otras palancas de tamaño

La división de código es la palanca grande, pero no la única. Estas complementan, y algunas son más baratas de aplicar:

Palanca Qué hacer Ahorro típico
Importar solo lo que se usa import { format } from 'date-fns' en lugar de import * as fns Depende: mucho en bibliotecas sin tree-shaking
Elegir bibliotecas más ligeras date-fns o Intl en lugar de moment (que además no se puede podar) 60–70 KB comprimidos
Iconos individuales import { BiBicycle } from 'react-icons/bi' en lugar del paquete entero Decenas de KB
Cargar bibliotecas bajo demanda const { jsPDF } = await import('jspdf') dentro del manejador de «Exportar informe» Todo el peso de la biblioteca
import() de datos const { barrios } = await import('../datos/barrios.json') para catálogos grandes El tamaño del JSON
Compresión en el servidor Activar gzip y brotli en el servidor o CDN Brotli suele mejorar gzip un 15–20 %
Quitar dependencias muertas npx depcheck para localizar lo que ya nadie importa Variable, a veces sorprendente
Objetivo de navegadores moderno build.target: 'es2020' en Vite, sin transpilar de más 5–15 % del código propio

Un ejemplo de carga bajo demanda dentro de un manejador, que es un patrón muy útil y a menudo olvidado:

// src/paginas/PaginaTaller.jsx (fragmento)
async function manejarExportarInforme() {
  setExportando(true);
  try {
    // La biblioteca de PDF (~90 KB) solo llega si alguien pulsa el botón
    const { generarInformePdf } = await import('../utilidades/informePdf.js');
    await generarInformePdf(incidencias);
    mostrarAviso('exito', 'Informe generado correctamente.');
  } catch {
    mostrarAviso('error', 'No se ha podido generar el informe.');
  } finally {
    setExportando(false);
  }
}

Aquí no hace falta lazy ni Suspense: no se está cargando un componente, sino una función. import() a secas dentro de una función async es la herramienta exacta, y el try/catch cubre el fallo de red.

Y una advertencia sobre la compresión que a menudo se malinterpreta: gzip y brotli reducen la transferencia, no el trabajo de la CPU. Un paquete de 900 KB que se transfiere como 270 KB sigue teniendo que analizarse y ejecutarse como 900 KB. En móviles, eso suele pesar más que la descarga.

  1. Suspense es mucho más que esto

<Suspense> ha aparecido aquí como «lo que se muestra mientras llega un fragmento», y esa es solo su aplicación más sencilla. Su verdadero papel en React es más general: es el mecanismo por el que un componente declara que todavía no puede renderizarse y delega la espera a un ancestro.

Sobre esa idea se construyen cosas que no caben en este módulo:

  • Componentes que suspenden esperando datos, no código: useSuspenseQuery de TanStack Query, o el hook use() de React 19.
  • Streaming SSR: el servidor envía el HTML por partes y rellena los huecos de Suspense a medida que los datos llegan.
  • React Server Components, donde Suspense marca las fronteras entre lo que se renderiza en el servidor y lo que llega después.
  • Coordinación con useTransition (08-03) para que una navegación no muestre un fallback si la nueva pantalla llega lo bastante rápido.

Todo eso es el contenido de 10-03. Aquí basta con haber interiorizado el mecanismo: un componente suspende, el Suspense más cercano por encima muestra su fallback, y cuando la espera termina el árbol se completa.

Errores Comunes y Consejos

Llamar a lazy() dentro de un componente. Cada render crearía un tipo de componente distinto, React desmontaría el anterior y montaría uno nuevo, y la pantalla parpadearía indefinidamente. lazy() va siempre en el ámbito del módulo.

Olvidar el Suspense. Un componente perezoso sin ningún Suspense por encima lanza un error explícito en tiempo de ejecución. No es opcional.

Poner un solo Suspense en la raíz. Cada navegación sustituiría toda la aplicación, cabecera incluida, y parecería una recarga. Coloca los límites de suspensión donde quieras que ocurra la sustitución.

Hacer perezosa la ruta de entrada. Añade una petición en serie en el camino crítico y empeora el LCP sin ahorrar un solo byte.

Hacer perezosos los componentes de error. Si lo que falla es la red, el fragmento del error tampoco llegará. PaginaErrorRuta y LimiteDeError van siempre en el paquete principal.

Dividir demasiado. Veinte fragmentos de 3 KB son peores que dos de 30 KB: cada petición tiene su latencia. Divide por unidades con sentido —una ruta, un diálogo, una biblioteca pesada—, no por fichero.

Suponer que el módulo exporta por defecto. Si es una exportación nombrada, lazy fallará con un error confuso. Adáptalo con .then((m) => ({ default: m.LoQueSea })).

Consejo: mide siempre antes y después. npm run build antes de tocar nada, apunta la cifra comprimida, divide, vuelve a construir y compara. Sin ese par de números no sabes si has mejorado algo.

Consejo: prueba con la red ralentizada. En las herramientas del navegador, pon la red en «3G lento» y navega. Ahí se ve de verdad si los esqueletos funcionan, si la precarga llega a tiempo y si el fallo de fragmento está bien gestionado.

Consejo: prueba el escenario del despliegue. Abre la aplicación, vuelve a construir con un cambio (los hashes cambiarán), y navega en la pestaña vieja a una ruta no visitada. Debe aparecer tu mensaje de «versión nueva disponible», no una pantalla en blanco.

Consejo: revisa el mapa del paquete de vez en cuando. Una dependencia pesada se cuela en el paquete principal con una sola importación estática despistada, y no hay ningún aviso que lo señale.

Ejercicios

Ejercicio 1. Clasifica cada elemento de CicloUrbano en estático, perezoso por ruta o perezoso por componente, y justifica cada decisión en una frase.

# Elemento
a Cabecera
b PaginaTaller (solo rol operario)
c Modal (usado por tres pantallas distintas)
d LimiteDeError
e GraficaOcupacion (biblioteca de 95 KB comprimidos)
f PaginaCatalogo (ruta índice)
g EtiquetaEstado (3 KB)
h Un editor de texto enriquecido para las notas de incidencia

Ejercicio 2. Este código tiene cuatro errores relacionados con la carga perezosa. Encuéntralos, explica qué provoca cada uno y escribe la versión corregida.

import { lazy, Suspense } from 'react';

function PaginaEstaciones() {
  const [estacionAbierta, setEstacionAbierta] = useState(null);
  const PanelDetalle = lazy(() => import('../componentes/PanelDetalle.jsx'));

  return (
    <div>
      <ListaEstaciones alAbrir={setEstacionAbierta} />
      {estacionAbierta && <PanelDetalle estacion={estacionAbierta} />}
    </div>
  );
}

// src/rutas.jsx
const PaginaErrorRuta = lazy(() => import('./paginas/PaginaErrorRuta.jsx'));
export const router = createBrowserRouter([
  { path: '/', element: <Diseno />, errorElement: <PaginaErrorRuta /> }
]);

Ejercicio 3. El equipo de CicloUrbano despliega tres veces al día. Los usuarios que dejan la pestaña abierta durante horas informan de pantallas en blanco al navegar al taller por la tarde. Explica la causa exacta, describe la solución completa —incluyendo el orden de anidamiento de los componentes— y di por qué el botón principal debe recargar la página en vez de reintentar la importación.

Soluciones

Solución 1.

# Clasificación Justificación
a Estático Forma parte del marco: se necesita en el primer pintado de todas las rutas
b Perezoso por ruta Solo lo ve un rol minoritario; es el caso ideal de división por ruta
c Estático Lo usan tres pantallas, así que acabaría duplicado o en el fragmento común igualmente; y es pequeño
d Estático Componente de error: si falla la red, su fragmento tampoco llegaría
e Perezoso por componente, con aislamiento por módulo 95 KB para una gráfica dentro de un desplegable de una pestaña. Debe ser el único módulo que importa la biblioteca
f Estático Ruta de entrada: hacerla perezosa añade una petición en serie sin ahorrar nada
g Estático 3 KB no compensan una petición HTTP; además se usa en todas las tarjetas
h Perezoso por componente Biblioteca pesada que solo hace falta al empezar a escribir una incidencia. Precárgalo al enfocar el campo

Solución 2. Los cuatro errores:

  1. lazy() dentro del componente. En cada render se crea un componente perezoso nuevo, React desmonta el anterior y monta otro: parpadeo infinito y descargas repetidas. Debe ir en el ámbito del módulo.
  2. Falta el Suspense. PanelDetalle es perezoso y no tiene ningún límite de suspensión por encima: React lanzará un error al intentar renderizarlo.
  3. PaginaErrorRuta es perezosa. Es el componente que debe mostrarse cuando algo falla, incluido un fallo de red; si su propio fragmento no llega, no hay nada que mostrar. Va siempre en el paquete principal.
  4. Falta importar useState. Un error trivial, pero real: el fichero solo importa lazy y Suspense.

Versión corregida:

import { lazy, Suspense, useState } from 'react';
import EsqueletoPanel from '../componentes/EsqueletoPanel.jsx';

// Fuera del componente: se evalúa una sola vez
const PanelDetalle = lazy(() => import('../componentes/PanelDetalle.jsx'));

function PaginaEstaciones() {
  const [estacionAbierta, setEstacionAbierta] = useState(null);

  return (
    <div>
      <ListaEstaciones alAbrir={setEstacionAbierta} />
      {estacionAbierta && (
        <Suspense fallback={<EsqueletoPanel />}>
          <PanelDetalle estacion={estacionAbierta} />
        </Suspense>
      )}
    </div>
  );
}
// src/rutas.jsx
import PaginaErrorRuta from './paginas/PaginaErrorRuta.jsx';   // estático, siempre

export const router = createBrowserRouter([
  { path: '/', element: <Diseno />, errorElement: <PaginaErrorRuta /> }
]);

Solución 3. Causa exacta: Vite incluye un hash del contenido en el nombre de cada fragmento (PaginaTaller-Bq7Wm3.js). La pestaña abierta por la mañana sigue ejecutando el rutas.jsx de esa versión, que apunta a los hashes antiguos. Al desplegar por la tarde, el servidor sirve fragmentos con hashes nuevos y borra —o deja de referenciar— los antiguos. Cuando el usuario navega al taller, ruta que aún no había visitado, el import() pide un fichero que devuelve 404, la promesa se rechaza, nadie captura el error y la pantalla se queda en blanco (o con el esqueleto para siempre, si el fallback sigue montado).

Solución completa: envolver cada ruta perezosa en un LimiteDeCarga con este orden de anidamiento, que es el único que funciona:

LimiteDeError  (por FUERA: captura el rechazo de la promesa)
  └── Suspense  (por DENTRO: gestiona la espera)
        └── Componente perezoso

Si se invirtiera, el límite de error se destruiría al suspenderse el árbol y no quedaría nadie para capturar el fallo. El respaldo del límite debe distinguir el error de importación dinámica (Failed to fetch dynamically imported module) del resto, dar un mensaje comprensible —«puede que haya una versión nueva disponible»— y ofrecer una acción.

Por qué recargar y no reintentar: reintentar ejecuta el mismo import(), que apunta al mismo hash inexistente, así que fallaría igual tantas veces como se pulse. Recargar la página vuelve a pedir index.html, que trae las referencias a los fragmentos nuevos, y desde ese momento todo funciona. El reintento sin recargar solo tiene sentido en el otro escenario —una interrupción momentánea de red con el mismo despliegue vigente—, y por eso se ofrece como acción secundaria.

Como mejora adicional del equipo: publicar un fichero de versión y comprobarlo periódicamente para avisar al usuario («hay una versión nueva, recarga cuando quieras») antes de que se encuentre con el error, y conservar los fragmentos antiguos en el servidor durante unos días en lugar de borrarlos en cada despliegue.

Conclusión

Memorizar reduce el trabajo que React hace mientras el usuario interactúa; dividir el código reduce lo que el navegador tiene que descargar y ejecutar antes de que el usuario pueda interactuar siquiera. Son problemas distintos y ninguna de las dos técnicas sustituye a la otra: el paquete único de CicloUrbano pesaba 271 KB comprimidos —910 KB de JavaScript que analizar y ejecutar— con la mitad dedicada a pantallas que la mayoría de visitas no abre.

El método ha sido el mismo de todo el módulo: medir primero. npm run build da la cifra y el aviso de Rollup sobre los 500 KB; rollup-plugin-visualizer dice qué ocupa ese espacio, que es lo que la cifra sola no cuenta. Sobre esa medición, el mecanismo: el import() dinámico es la frontera automática entre fragmentos —una sintaxis de JavaScript, sin ninguna configuración—, y React.lazy es el puente entre esa promesa y JSX, con dos exigencias claras (exportación por defecto y llamada en el ámbito del módulo) y un comportamiento que hay que tener presente: la primera vez suspende y muestra el fallback, las siguientes es instantáneo.

La división por ruta es la que más rinde, y en CicloUrbano ha dejado el JavaScript inicial en 128 KB comprimidos, un 53 % menos, cumpliendo por fin el presupuesto de 08-01. Igual de importante es lo que no se ha dividido: la ruta de entrada, porque hacerla perezosa añade una petición en serie en el camino crítico sin ahorrar un byte; los componentes del marco; y sobre todo los componentes de error, porque un fragmento de error que no llega por culpa de la red no sirve para nada. A eso se suma la división por componente —el DialogoReserva que solo se carga al abrirse y la GraficaOcupacion aislada como único importador de una biblioteca de 95 KB— y la carga de bibliotecas dentro de un manejador con import() a secas, sin lazy ni Suspense.

Después viene todo lo que convierte una división correcta en una experiencia buena: la precarga al pasar el ratón, al enfocar con el teclado y al tocar en móvil, que aprovecha los milisegundos de decisión del usuario para que el fallback no llegue a verse; los esqueletos con la altura y la forma del contenido real, con role="status", texto accesible oculto y respeto a prefers-reduced-motion, en lugar de un «Cargando…» que provoca saltos de maquetación y rompe el contexto; y la gestión del fallo de carga, con LimiteDeError por fuera y Suspense por dentro —el orden inverso no captura nada—, un mensaje que distingue el fallo de fragmento del resto y un botón que recarga, porque tras un despliegue nuevo reintentar el mismo import() pediría eternamente un hash que ya no existe. Y las palancas complementarias: importar selectivamente, elegir bibliotecas ligeras, import() de datos, brotli en el servidor y un objetivo de navegadores moderno, con el recordatorio de que la compresión reduce la transferencia pero no el trabajo de CPU.

Queda dicho también que Suspense es mucho más que un fallback de carga perezosa —es el mecanismo general por el que un componente declara que aún no puede renderizarse— y que su historia completa, junto con los Server Components, es 10-03.

Con esto, CicloUrbano tiene todas las herramientas del módulo: sabe qué renderizado evitar, cómo estabilizar identidades, cómo abaratar cálculos y cómo repartir su código en el tiempo. Lo que todavía no tiene es la prueba de que algo de esto sirva. Cada cifra de esta lección —271 KB, 128 KB, 53 %— viene de una medición, y cada memo y cada useMemo de las anteriores debería venir de otra. Falta la herramienta que las produce, que dice qué componente se repintó, cuánto tardó y por qué, y que convierte una sospecha en un diagnóstico. La próxima lección es Medir el Rendimiento con React DevTools Profiler.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados