Skip to content

Fix: React.lazy and Suspense Errors (Element Type Invalid, Loading Chunk Failed)

FixDevs ·

Quick Answer

How to fix React.lazy and Suspense errors — Element type is invalid, A React component suspended while rendering, Loading chunk failed, and lazy import mistakes with named vs default exports.

The Error

You use React.lazy() for code splitting and get one of these errors:

Error: Element type is invalid. Expected a string (for built-in components) or a class/function
(for composite components) but got: object.

Or:

A React component suspended while rendering, but no fallback UI was specified.
Add a <Suspense fallback=...> component higher in the tree to provide a loading indicator
or placeholder to display.

Or at runtime when a user navigates:

ChunkLoadError: Loading chunk 3 failed.
(error: https://example.com/static/js/3.chunk.js)

Or in the console:

Warning: ReactDOM.render is no longer supported in React 18.

Why This Happens

React.lazy() enables dynamic imports for code splitting — the component bundle is loaded on demand. Problems arise from:

  • Named export instead of default exportReact.lazy() only works with modules that have a default export.
  • Missing <Suspense> wrapper — any lazily loaded component must be wrapped in <Suspense>.
  • <Suspense> in the wrong place — the Suspense boundary must be an ancestor of the lazy component, not the lazy component itself.
  • Chunk loading failures — the JS chunk file could not be fetched (network error, 404, stale cached file after a new deployment).
  • Server-side rendering (SSR) without a compatible setupReact.lazy() does not work with SSR out of the box (use React.lazy with Suspense on the client side only, or use a framework like Next.js).
  • Circular imports — dynamic imports in circular dependency chains can produce undefined modules.

Fix 1: Use Default Exports with React.lazy

React.lazy() requires the imported module to have a default export that is a React component. Named exports are not supported directly.

Broken — named export:

// components/UserProfile.jsx
export function UserProfile() {   // Named export
  return <div>Profile</div>;
}

// App.jsx
const UserProfile = React.lazy(() => import("./components/UserProfile"));
// Error: Element type is invalid — the module exports {UserProfile}, not a default

Fixed — add a default export:

// components/UserProfile.jsx
export function UserProfile() {
  return <div>Profile</div>;
}

export default UserProfile;  // Add default export

Alternative — re-export the named export as default in the import:

// App.jsx — wrap named export to make React.lazy work
const UserProfile = React.lazy(() =>
  import("./components/UserProfile").then(module => ({
    default: module.UserProfile,  // Extract named export as default
  }))
);

This pattern is useful when you cannot modify the component file (e.g., a third-party library).

Pro Tip: Make it a convention to always add export default to component files, even when they also have named exports. It removes all ambiguity with React.lazy() and simplifies imports throughout the codebase.

Fix 2: Wrap Lazy Components in Suspense

Every React.lazy() component must be wrapped in a <Suspense> boundary. Without it, React throws an error when the component suspends during loading.

Broken — no Suspense:

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

function App() {
  return (
    <div>
      <Dashboard />  {/* Error: No Suspense boundary found */}
    </div>
  );
}

Fixed — wrap in Suspense:

import React, { Suspense, lazy } from "react";

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

function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <Dashboard />
      </Suspense>
    </div>
  );
}

The fallback prop accepts any React element — a spinner, skeleton screen, or simple text. It renders while the lazy component’s bundle is being fetched.

Suspense can wrap multiple lazy components:

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

function App() {
  return (
    <Suspense fallback={<PageSpinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/profile" element={<Profile />} />
      </Routes>
    </Suspense>
  );
}

One <Suspense> wrapping all routes is a common and valid pattern — it shows the spinner whenever any route is loading.

Fix 3: Place Suspense at the Right Level

The <Suspense> boundary must be an ancestor of the component that suspends — not the component itself, and not a sibling.

Broken — Suspense inside the lazy component:

// Dashboard.jsx — lazy loaded
function Dashboard() {
  return (
    <Suspense fallback={<div>Loading chart...</div>}>
      <ExpensiveChart />
    </Suspense>
  );
}

// App.jsx — no Suspense wrapping Dashboard
const Dashboard = lazy(() => import("./Dashboard"));

function App() {
  return <Dashboard />;  // Dashboard itself suspends during load — no boundary above it
}

Fixed — Suspense in the parent:

function App() {
  return (
    <Suspense fallback={<div>Loading dashboard...</div>}>
      <Dashboard />  {/* Dashboard can now suspend safely */}
    </Suspense>
  );
}

The inner <Suspense> inside Dashboard is valid for nested lazy loading — but the outer one in App is required for Dashboard itself to load.

Fix 4: Fix ChunkLoadError After Deployment

ChunkLoadError: Loading chunk X failed happens when:

  1. A user loads your app (gets index.html pointing to old chunk filenames).
  2. You deploy a new version (chunk filenames change due to content hashing).
  3. The user navigates to a lazy-loaded route — the browser requests the old chunk URL, which no longer exists (404).

Fix — catch ChunkLoadError and reload:

class ChunkErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    if (error.name === "ChunkLoadError") {
      return { hasError: true };
    }
    return null;
  }

  componentDidCatch(error) {
    if (error.name === "ChunkLoadError") {
      // Reload the page to get the latest chunks
      window.location.reload();
    }
  }

  render() {
    if (this.state.hasError) {
      return <div>Updating... please wait.</div>;
    }
    return this.props.children;
  }
}

Wrap your lazy routes:

function App() {
  return (
    <ChunkErrorBoundary>
      <Suspense fallback={<PageSpinner />}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </ChunkErrorBoundary>
  );
}

Alternative — handle in the lazy import:

const Dashboard = lazy(() =>
  import("./Dashboard").catch(() => {
    window.location.reload();
    return new Promise(() => {}); // Never resolves — page reloads instead
  })
);

Server-side fix: Configure your server to never cache index.html but cache chunk files aggressively:

# Never cache index.html
location = /index.html {
  add_header Cache-Control "no-cache, no-store, must-revalidate";
}

# Cache chunks forever (they have content hashes in filenames)
location /static/ {
  add_header Cache-Control "public, max-age=31536000, immutable";
}

Fix 5: Add an Error Boundary for Lazy Loading Failures

<Suspense> handles the loading state, but if the import fails (network error, module not found), you need an Error Boundary to catch the error:

import React from "react";

class LazyErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { error: null };
  }

  static getDerivedStateFromError(error) {
    return { error };
  }

  render() {
    if (this.state.error) {
      return (
        <div>
          <p>Failed to load this section.</p>
          <button onClick={() => window.location.reload()}>Retry</button>
        </div>
      );
    }
    return this.props.children;
  }
}

// Usage
function App() {
  return (
    <LazyErrorBoundary>
      <Suspense fallback={<div>Loading...</div>}>
        <Dashboard />
      </Suspense>
    </LazyErrorBoundary>
  );
}

With react-error-boundary library (recommended):

import { ErrorBoundary } from "react-error-boundary";

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div>
      <p>Something went wrong: {error.message}</p>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Suspense fallback={<div>Loading...</div>}>
        <Dashboard />
      </Suspense>
    </ErrorBoundary>
  );
}

Fix 6: Fix React.lazy with SSR

React.lazy() is a client-only feature. Using it in a server-side rendered app (Next.js, Remix, custom SSR) without proper handling causes errors:

Next.js — use dynamic import instead:

import dynamic from "next/dynamic";

// Next.js's dynamic() is the SSR-compatible equivalent of React.lazy
const Dashboard = dynamic(() => import("./Dashboard"), {
  loading: () => <div>Loading...</div>,
  ssr: false,  // Disable SSR for this component if needed
});

Generic SSR — skip lazy on server:

const Dashboard = typeof window !== "undefined"
  ? React.lazy(() => import("./Dashboard"))
  : () => null;  // Render nothing on server

This is a workaround — prefer using a framework that handles SSR + lazy loading properly.

Fix 7: Lazy Load at the Route Level

The most impactful use of React.lazy is route-based code splitting — each route loads its own bundle only when navigated to:

import React, { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";

const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const AdminPanel = lazy(() => import("./pages/AdminPanel"));

function App() {
  return (
    <Suspense fallback={<div className="page-loader">Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/admin" element={<AdminPanel />} />
      </Routes>
    </Suspense>
  );
}

This pattern keeps the initial bundle small — users only download code for pages they actually visit.

Verify bundle splitting worked by checking your build output:

npm run build
# Look for multiple chunk files:
# dist/static/js/main.abc123.js       (main bundle)
# dist/static/js/Dashboard.def456.js  (Dashboard chunk)
# dist/static/js/Settings.ghi789.js   (Settings chunk)

If all code is in one file, the lazy imports are not splitting correctly — check your bundler (Webpack/Vite) configuration.

Still Not Working?

Check for React version. React.lazy requires React 16.6+. Suspense for data fetching (not just lazy loading) requires React 18+. Run npm list react to check your version.

Check for Vite-specific issues. Vite supports dynamic imports natively. If chunks are not being created, check vite.config.js for build.rollupOptions.output.manualChunks settings that may be overriding the automatic splitting.

Check for import path typos. A typo in the import path causes the lazy import to fail at runtime with a module not found error. Verify the path resolves correctly by checking if the static import version works.

Check for fast refresh / HMR conflicts. In development, hot module replacement can sometimes cause issues with lazy components. Try a full page refresh (Ctrl+Shift+R) to rule out stale module state.

For related React rendering issues, see Fix: React Invalid Hook Call and Fix: React: Objects are not valid as a React child. For chunk-related errors after deployment, see Fix: Loading chunk failed.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles