Por qué React.lazy() necesita una exportación por defecto y cómo cargar las nombradas
Entienda qué recibe React.lazy() de una importación dinámica, por qué las exportaciones nombradas lo dañan, cómo remapearlas a la predeterminada, y cómo encajan Suspense y el dividir código.
Fuentes: React.lazy() Looks Simple Until the Export Is Wrong
React.lazy() es una de las APIs más cortas en React: se envuelve una importación dinámica, el resultado se renderiza dentro de Suspense, y el bundler divide ese componente en su propio chunk. El problema radica en un requisito oculto por la sintaxis: el módulo importado debe exponer el componente como su exportación por defecto. Si se cambia el archivo a una exportación con nombre, la aplicación falla debido a un error generado en lo más profundo de React que no indica la causa real. Este artículo explica qué valor fluye realmente a través de lazy(), por qué las exportaciones con nombre lo dañan, dos formas de solucionar el problema y un hábito de depuración que es útil más allá de esta API.
La llamada habitual se ve así:
const Dashboard = lazy(() => import("./Dashboard"));
Nada de esto parece arriesgado, y precisamente por eso el fallo resulta confuso cuando ocurre.
El camino correcto: una exportación por defecto
Comience con un archivo Dashboard.jsx que defina un componente y lo exporte como el predeterminado.
const Dashboard = () => {
return <div>Dashboard</div>;
};
export default Dashboard;
En el archivo que lo utiliza, importe lazy de React y úselo para referirse al módulo.
import { lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
La mayoría de las veces nadie piensa en lo que sucede entre esas dos líneas; React simplemente carga el componente cuando se renderiza por primera vez. Lo interesante es la expresión dentro de la función flecha.
import("./Dashboard")
Eso es fundamentalmente diferente de una instrucción de importación estática:
import Dashboard from "./Dashboard";
La primera forma es una importación dinámica, y una importación dinámica devuelve un Promise. Esperar a que se resuelva facilita comprender el resultado:
const module = await import("./Dashboard");
Lo que se devuelve no es el componente Dashboard. Se trata de un objeto de espacio de nombres de módulo, un objeto cuyas propiedades son las exportaciones del módulo. Debido a que el archivo termina con esta línea:
export default Dashboard;
el módulo resuelto tiene más o menos esta estructura:
{
default: Dashboard
}
Esa estructura es precisamente la que está diseñada para ser consumida por React.lazy().
Un pequeño cambio que lo rompe
Ahora supongamos que el equipo prefiere exportaciones nombradas. El archivo anterior se veía así:
const Dashboard = () => {
return <div>Dashboard</div>;
};
export default Dashboard;
Se vuelve a escribir para exportar el componente por nombre:
export const Dashboard = () => {
return <div>Dashboard</div>;
};
La llamada lazy permanece exactamente igual:
const Dashboard = lazy(() => import("./Dashboard"));
Nada parece estar mal. El componente existe, el archivo existe y la ruta es correcta. Una importación estática normal del mismo archivo funcionaría sin problemas:
import { Dashboard } from "./Dashboard";
Lo que cambió es la forma del objeto módulo.
Comparación de las dos formas de módulos
Una exportación por defecto, escrita de esta manera:
export default Dashboard;
produce un módulo que se parece más o menos a esto:
{
default: Dashboard
}
Una exportación con nombre, escrita de esta forma:
export const Dashboard = ...
produce en su lugar un módulo con esta forma:
{
Dashboard: Dashboard
}
React.lazy() lee el componente desde un lugar específico:
module.default
Nunca examina la propiedad con nombre:
module.Dashboard
Por lo tanto, las exportaciones con nombre no son incorrectas en sí mismas. El problema radica únicamente en que el módulo ya no coincide con la forma de la que depende lazy(). En el desarrollo, React suele indicar que el elemento lazy se resolvió en undefined o que el tipo del elemento es inválido, lo cual describe el síntoma y no la falta de la clave default.
Por qué lazy() busca el valor por defecto
El diseño se vuelve más claro una vez que se observa qué se le pasa a lazy().
lazy(() => import("./Dashboard"))
React no recibe un componente aquí. Recibe una función de carga, la cual devuelve una Promise que eventualmente se resuelve en un módulo. Luego React toma la propiedad default de ese módulo y la trata como el componente. La cadena es la siguiente:
lazy()
↓
loader function
↓
import("./Dashboard")
↓
Promise
↓
module
↓
module.default
↓
React component
Cuando el módulo contiene una entrada default, React tiene lo que necesita:
{
default: Dashboard
}
Cuando el módulo solo contiene una entrada con nombre, no existe un default que React pueda leer:
{
Dashboard: Dashboard
}
Ahí es donde ocurre la discrepancia. Leer default es una convención razonable: es el nombre de exportación con el que todos los módulos pueden ponerse de acuerdo sin que React necesite saber nada sobre la nomenclatura utilizada.
Solución uno: cambiar a una exportación por defecto
Si el componente es lo principal que ofrece un archivo, la solución más sencilla es exportarlo como el valor por defecto.
const Dashboard = () => {
return <div>Dashboard</div>;
};
export default Dashboard;
Con esto en su lugar, la llamada original funciona como se esperaba:
const Dashboard = lazy(() => import("./Dashboard"));
Para archivos que representan un único componente principal, esta es una opción limpia y convencional.
Solución dos: mantener la exportación con nombre y reasignarla
A veces cambiar la exportación no es una opción ni deseable, por ejemplo cuando un código base exige exportaciones con nombre mediante una regla de lint o cuando el archivo exporta varios componentes. La versión con nombre puede permanecer tal como está:
export const Dashboard = () => {
return <div>Dashboard</div>;
};
Aún se puede utilizar lazy(); solo hay que indicarle qué exportación considerar como predeterminada. El cargador agrega un .then() al importación dinámica y devuelve un nuevo objeto que coloca module.Dashboard bajo la clave default.
const Dashboard = lazy(() =>
import("./Dashboard").then((module) => ({
default: module.Dashboard,
}))
);
Parece extraño la primera vez, pero la lógica es sencilla:
Load the module
↓
Get the Dashboard export
↓
Put it under "default"
↓
Give it back to React.lazy()
En otras palabras, este objeto:
{
Dashboard: Dashboard
}
se transforma en este otro antes de que React lo vea:
{
default: Dashboard
}
Ahora React.lazy() recibe exactamente la estructura que espera. Si un proyecto carga de forma diferida muchas exportaciones nombradas, vale la pena envolver este patrón en una pequeña función auxiliar compartida para que el remapeo esté en un solo lugar. Además, mantenga cada llamada a lazy() en el ámbito del módulo y no dentro del cuerpo de un componente; declararlo durante el renderizado crea un nuevo componente lazy cada vez y restablece su estado.
Por qué las importaciones estáticas nunca tienen este problema
Con una importación nombrada estática, indica explícitamente qué exportación desea:
import { Dashboard } from "./Dashboard";
JavaScript resuelve automáticamente la referencia a Dashboard. Con una importación por defecto, en cambio, está solicitando la exportación predeterminada:
import Dashboard from "./Dashboard";
No obstante, un cargador diferido no selecciona nada:
lazy(() => import("./Dashboard"))
Se le entrega a React todo el objeto del módulo, y React debe seleccionar algo de él. Por eso la exportación por defecto solo es importante en el caso de carga diferida.
Cómo encaja Suspense en el flujo
React.lazy() rara vez aparece sin Suspense:
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
Esa combinación tiene sentido una vez que recuerdas que el componente no está disponible de inmediato. El navegador puede tener que descargar un fragmento de JavaScript separado antes de que React pueda renderizar Dashboard, y durante ese tiempo React necesita algo más para mostrar. La propiedad fallback se encarga de proporcionarlo. La secuencia completa es la siguiente:
React needs Dashboard
↓
Dashboard hasn't been loaded
↓
Dynamic import starts
↓
Suspense shows fallback
↓
JavaScript chunk loads
↓
Module resolves
↓
React gets module.default
↓
Dashboard renders
Al considerarse como una secuencia, lazy() resulta mucho menos misterioso: Suspense gestiona el período de espera, y lo que se renderiza al final es la exportación default. Un punto relacionado es que una descarga de chunk fallida rechaza la Promise, y Suspense no maneja ese caso; es necesario un límite de errores alrededor del componente lazy para capturarlo.
La verdadera ventaja es el split de código
La razón principal para utilizar importaciones dinámicas es precisamente el split de código. Sin una llamada como esta:
lazy(() => import("./Dashboard"))
el código del Dashboard probablemente se incluiría en el JavaScript necesario para iniciar la aplicación. Con las importaciones dinámicas, el bundler puede moverlo a su propio chunk. Conceptualmente, la salida se divide en un bundle inicial:
Initial bundle
├── React
├── App
└── Home
y en un chunk que se carga posteriormente:
Dashboard chunk
└── Dashboard
El código del panel de control se descarga solo cuando el usuario realmente lo necesita. Algunos ejemplos adecuados son:
- Paneles de control para administradores
- Páginas de análisis
- Pantallas de configuración
- Editores de gran tamaño
- Rutas que los usuarios visitan raramente
- Componentes pesados que incluyen muchas dependencias
Eso no significa que todos los componentes deban ser de carga diferida. Cada división añade una solicitud de red y un estado de carga, por lo que un botón pequeño no beneficia en nada con su propio fragmento de código. El objetivo no es hacer que todo sea de carga diferida, sino mantener el paquete inicial libre de código que el usuario aún no necesita.
Los errores provenientes de los componentes internos de React no siempre son fallos de React
Cuando surge una excepción desde el interior de los componentes internos de React, es tentador sospechar que se trata de un problema en React mismo. La traza de llamadas puede incluir frames como estos:
lazyInitializer
resolveLazy
beginWork
Y la conclusión lógica es que algo dentro de React se ha roto. Antes de seguir por ese camino, verifica el contrato de la API a la que estás haciendo llamadas. La pregunta clave aquí es qué espera React.lazy() que devuelva su cargador. En forma simplificada, la respuesta es:
Promise<{
default: Component
}>
Compara eso con lo que el módulo proporciona en realidad. Si el archivo contiene esto:
export const Dashboard = ...
entonces el valor resuelto es, en efecto, este:
{
Dashboard: Dashboard
}
Al compararlos lado a lado, la causa es obvia. React no está roto; las exportaciones del módulo y el contrato del cargador simplemente no coinciden.
Un modelo mental para leer las llamadas a lazy()
En lugar de interpretar esta línea como “cargar en segundo plano el Dashboard”:
const Dashboard = lazy(() => import("./Dashboard"));
es útil leerla como un pequeño pipeline:
When Dashboard is needed
↓
run the dynamic import
↓
get the module
↓
take its default export
↓
use that as the component
Ese modelo permite diagnosticar estos problemas rápidamente. También explica por qué el primero de los siguientes archivos funciona de inmediato:
// Dashboard.jsx
export default function Dashboard() {
return <div>Dashboard</div>;
}
Mientras que el segundo necesita trabajo adicional:
// Dashboard.jsx
export const Dashboard = () => {
return <div>Dashboard</div>;
};
cuando se carga con esta llamada:
lazy(() => import("./Dashboard"));
La lección más amplia sobre depuración
Este pequeño detalle de React conlleva una lección más importante. Cuando una API se comporta de manera inesperada, debe uno ir más allá de la sintaxis que se escribió y examinar el valor que realmente viaja a través de la API. Una llamada como la siguiente parece trivial:
lazy(() => import("./Dashboard"))
En realidad, se trata de una cadena de transformaciones:
dynamic import
↓
Promise
↓
module object
↓
default export
↓
React component
Una vez que esa cadena se vuelve visible, es fácil comprender el error, y el mismo enfoque funciona en todo JavaScript. En lugar de preguntarse por qué una biblioteca está lanzando un error, hay que averiguar exactamente qué se está pasando a la función y qué espera recibir a cambio. A veces, toda la diferencia radica en el nombre de una sola propiedad:
default
Puntos clave
import()devuelve un objeto de módulo, no un componente, yReact.lazy()lee el componente desde su propiedaddefault.- Las exportaciones nombradas están bien; solo es necesario remapearlas con
.then((module) => ({ default: module.Name }))cuando se carguen de forma perezosa. - Declare los componentes perezosos en el ámbito del módulo, envuélvalos en
Suspensepara los estados de carga y agregue un límite de errores para las descargas fallidas de los chunks.