Home / Articles

Why React.lazy() Needs a Default Export and How to Load Named Ones

Understand what React.lazy() receives from a dynamic import, why named exports break it, how to remap them to default, and how Suspense and code splitting fit.

Publisher: REACTAPP.TOP9 min read2056 words

This article is published in English.

Sources: React.lazy() Looks Simple Until the Export Is Wrong

React.lazy() is one of the shortest APIs in React: wrap a dynamic import, render the result inside Suspense, and the bundler splits that component into its own chunk. The catch is a contract that the syntax hides: the imported module must expose the component as its default export. Switch the file to a named export and the app fails with an error thrown from deep inside React that does not point at the real cause. This article explains what value actually flows through lazy(), why named exports break it, two ways to fix the problem, and a debugging habit that applies well beyond this one API.

The usual call looks like this:

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

Nothing about it seems risky, which is exactly why the failure is confusing when it happens.

The happy path: a default export

Start with a Dashboard.jsx file that defines a component and exports it as the default.

const Dashboard = () => {
  return <div>Dashboard</div>;
};
export default Dashboard;

In the consuming file, import lazy from React and point it at the module.

import { lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard"));

Most of the time nobody thinks about what happens between those two lines; React simply loads the component when it is first rendered. The interesting part is the expression inside the arrow function.

import("./Dashboard")

That is fundamentally different from a static import statement:

import Dashboard from "./Dashboard";

The first form is a dynamic import, and a dynamic import evaluates to a Promise. Awaiting it makes the result easier to picture:

const module = await import("./Dashboard");

What comes back is not the Dashboard component. It is a module namespace object, an object whose properties are the module's exports. Because the file ends with this line:

export default Dashboard;

the resolved module has roughly this shape:

{
  default: Dashboard
}

That shape is precisely what React.lazy() is built to consume.

One small change that breaks it

Now suppose the team prefers named exports. The file previously looked like this:

const Dashboard = () => {
  return <div>Dashboard</div>;
};
export default Dashboard;

It is rewritten to export the component by name:

export const Dashboard = () => {
  return <div>Dashboard</div>;
};

The lazy call stays exactly the same:

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

Nothing looks wrong. The component exists, the file exists, and the path is correct. A regular static import of the same file would work without complaint:

import { Dashboard } from "./Dashboard";

What changed is the shape of the module object.

Comparing the two module shapes

A default export, written like this:

export default Dashboard;

produces a module that looks roughly like this:

{
  default: Dashboard
}

A named export, written like this:

export const Dashboard = ...

produces a module shaped like this instead:

{
  Dashboard: Dashboard
}

React.lazy() reads the component from one specific place:

module.default

It never looks at the named property:

module.Dashboard

So named exports are not wrong in themselves. The issue is purely that the module no longer matches the shape lazy() depends on. In development, React typically reports that the lazy element resolved to undefined or that the element type is invalid, which describes the symptom rather than the missing default key.

Why lazy() looks for default

The design becomes clearer once you look at what is handed to lazy().

lazy(() => import("./Dashboard"))

React does not receive a component here. It receives a loader function, which returns a Promise, which eventually resolves to a module. React then takes the default property of that module and treats it as the component. The chain looks like this:

lazy()
  ↓
loader function
  ↓
import("./Dashboard")
  ↓
Promise
  ↓
module
  ↓
module.default
  ↓
React component

When the module contains a default entry, React has what it needs:

{
  default: Dashboard
}

When the module only contains a named entry, there is no default for React to read:

{
  Dashboard: Dashboard
}

That is where the mismatch occurs. Reading default is a reasonable convention: it is the one export name that every module can agree on without React needing to know anything about your naming.

Fix one: switch to a default export

If the component is the main thing a file provides, the simplest fix is to export it as the default.

const Dashboard = () => {
  return <div>Dashboard</div>;
};
export default Dashboard;

With that in place, the original call works as intended:

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

For files that represent a single primary component, this is a clean and conventional choice.

Fix two: keep the named export and remap it

Sometimes changing the export is not an option or not desirable, for example when a codebase enforces named exports through a lint rule or when the file exports several components. The named version can stay as it is:

export const Dashboard = () => {
  return <div>Dashboard</div>;
};

You can still use lazy(); you just have to tell it which export to treat as the default. The loader chains a .then() onto the dynamic import and returns a new object that places module.Dashboard under the default key.

const Dashboard = lazy(() =>
  import("./Dashboard").then((module) => ({
    default: module.Dashboard,
  }))
);

It looks odd the first time, but the logic is simple:

Load the module
      ↓
Get the Dashboard export
      ↓
Put it under "default"
      ↓
Give it back to React.lazy()

In other words, this object:

{
  Dashboard: Dashboard
}

is transformed into this one before React sees it:

{
  default: Dashboard
}

Now React.lazy() receives exactly the shape it expects. If a project lazy-loads many named exports, it is worth wrapping this pattern in a small shared helper so the remapping lives in one place. Also keep every lazy() call at module scope rather than inside a component body; declaring it during render creates a new lazy component each time and resets its state.

Why static imports never hit this problem

With a static named import, you state explicitly which export you want:

import { Dashboard } from "./Dashboard";

JavaScript resolves the Dashboard binding for you. With a default import, you are asking for the default export instead:

import Dashboard from "./Dashboard";

A lazy loader, however, selects nothing:

lazy(() => import("./Dashboard"))

It hands React the entire module object, and React has to pick something out of it. That is why the default export matters only in the lazy case.

How Suspense fits into the flow

React.lazy() rarely appears without Suspense:

<Suspense fallback={<div>Loading...</div>}>
  <Dashboard />
</Suspense>

The pairing makes sense once you remember that the component is not available immediately. The browser may have to download a separate JavaScript chunk before React can render Dashboard, and during that time React needs something else to display. The fallback prop provides it. The complete sequence looks like this:

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

Seen as a sequence, lazy() is far less mysterious: Suspense covers the waiting period, and the default export is what gets rendered at the end. A related point is that a failed chunk download rejects the Promise, and Suspense does not handle that case; an error boundary around the lazy component is what catches it.

Code splitting is the real payoff

The main reason to use dynamic imports at all is code splitting. Without a call like this:

lazy(() => import("./Dashboard"))

the Dashboard code would likely be included in the JavaScript needed to start the application. With the dynamic import, the bundler can move it into its own chunk. Conceptually, the output separates into an initial bundle:

Initial bundle
├── React
├── App
└── Home

and a chunk that is fetched later:

Dashboard chunk
└── Dashboard

The Dashboard code then downloads only when a user actually needs it. Good candidates include:

  • Admin dashboards
  • Analytics pages
  • Settings screens
  • Large editors
  • Routes that users rarely visit
  • Heavy components that pull in large dependencies

That does not mean every component should be lazy. Each split adds a network request and a loading state, so a tiny button gains nothing from its own chunk. The goal is not to make everything lazy; it is to keep the initial bundle free of code the user does not need yet.

Errors from React internals are not always React bugs

When an exception surfaces from inside React's internals, it is tempting to suspect React itself. The stack trace may include frames such as these:

lazyInitializer
resolveLazy
beginWork

and the natural conclusion is that something inside React has broken. Before going down that path, check the contract of the API you are calling. Here the key question is what React.lazy() expects its loader to return. In simplified form, the answer is:

Promise<{
  default: Component
}>

Compare that with what the module actually provides. If the file contains this:

export const Dashboard = ...

then the resolved value is effectively this:

{
  Dashboard: Dashboard
}

Put side by side, the cause is obvious. React is not broken; the module's exports and the loader contract simply do not match.

A mental model for reading lazy() calls

Instead of reading this line as "lazy-load Dashboard":

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

it helps to read it as a small pipeline:

When Dashboard is needed
        ↓
run the dynamic import
        ↓
get the module
        ↓
take its default export
        ↓
use that as the component

That model makes these issues quick to diagnose. It also explains why the first of the following files works immediately:

// Dashboard.jsx
export default function Dashboard() {
  return <div>Dashboard</div>;
}

while the second one needs extra work:

// Dashboard.jsx
export const Dashboard = () => {
  return <div>Dashboard</div>;
};

when it is loaded with this call:

lazy(() => import("./Dashboard"));

The broader debugging lesson

This small React detail carries a larger lesson. When an API behaves unexpectedly, look past the syntax you typed and examine the value that actually travels through the API. A call like the following appears trivial:

lazy(() => import("./Dashboard"))

Underneath, it is a chain of transformations:

dynamic import
      ↓
Promise
      ↓
module object
      ↓
default export
      ↓
React component

Once that chain is visible, the error becomes easy to reason about, and the same approach pays off across JavaScript. Instead of asking why a library is throwing, ask what exactly you are passing to the function and what it expects to receive back. Sometimes the entire difference comes down to a single property name:

default

Key takeaways

  • import() resolves to a module object, not a component, and React.lazy() reads the component from its default property.
  • Named exports are fine; they just need to be remapped with .then((module) => ({ default: module.Name })) when loaded lazily.
  • Declare lazy components at module scope, wrap them in Suspense for loading states, and add an error boundary for failed chunk downloads.
  • Split heavy, rarely used screens, not small components.
  • When an error originates inside a library, compare the value you pass with the value the API contract expects before assuming a library bug.