javascriptreact-routerreact-router-domlazy-loading

React Router loader not executing in nested routes with dynamic modules


I'm using React-Router with dynamic route modules in my React app. The loader function for one of my routes doesn't seem to execute, and I don't see the expected console.log in the browser console.

Here's the relevant setup:

App.jsx

import React, { lazy } from "react";
import {
    RouterProvider,
    createBrowserRouter,
    createRoutesFromElements,
    Route,
} from "react-router-dom";

import ProtectedRoute from "./app/layout/ProtectedRoute.jsx";

// Lazy-loaded components
const ErrorPage = lazy(() => import("./layout/ErrorPage"));
import WorkplaceLayout from "./layout/WorkplaceLayout";
const LoginPage = lazy(() => import("./auth/LoginPage"));

// Dynamic module loader
const modules = {
    retail: lazy(() => import("./modules/retail/MainRoutes")),
    inventory: lazy(() => import("./modules/inventory/MainRoutes")),
};

const DynamicModuleRoutes = () => {
    const defaultModule = 'retail'; // or from localStorage
    return (
        <React.Suspense fallback={<div>Loading {defaultModule} module...</div>}>
            <ModuleRoutes />
        </React.Suspense>
    );
};

// Define the router with loaders and error elements
const router = createBrowserRouter(
    createRoutesFromElements(
        <>
            <Route element={<ProtectedRoute />}>
                <Route path="workplace" element={<WorkplaceLayout />}>
                    <Route
                        path=":defaultModule/*"
                        element={<DynamicModuleRoutes />}
                    />
                    {/*  other routes */}
                </Route>
            </Route>
            <Route
                path="login"
                element={<LoginPage />}
                errorElement={<ErrorPage />}
            />
            <Route path="*" element={<div>Unknown page</div>} />
        </>
    )
);

const App = () => {
    return <RouterProvider router={router} />;
};

export default App;

WorkplaceLayout.jsx

import React, { Suspense } from "react";
import { Outlet } from "react-router-dom";

const WorkplaceLayout = () => {
    return (
        <div className="workplace">
            <div className="app-sidebar">
                <div>Logo</div>
                <div>Menus</div>
            </div>
            <div className="canvas">
                {/* here I want to display loading */}
                <Suspense fallback={<h3>Loading</h3>}>
                    <Outlet />
                </Suspense>
            </div>
        </div>
    );
};

export default WorkplaceLayout;

Retail MainRoutes.jsx

import React from "react";
import { Route, Routes } from "react-router-dom";
import DashboardPage, { loader as dashboardLoader } from "./pages/Dashboard";

const MainRoutes = () => (
    <Routes>
        <Route
            index
            element={<DashboardPage />}
            loader={dashboardLoader}
            errorElement={<div>Error loading Dashboard</div>}
        />
        <Route path="invoice" element={<div>Invoices Page</div>} />
        <Route path="*" element={<div>Unknown retail page</div>} />
    </Routes>
);

export default MainRoutes;

Dashboard.jsx

import React from "react";
import { useLoaderData } from "react-router-dom";

const Dashboard = () => {
    const data = useLoaderData();

    return <div>Dashboard Data: {data}</div>;
};

export function loader() {
    console.log("Loader executed"); // this never appears
    return new Promise((resolve) => {
        setTimeout(() => resolve("Dashboard data loaded"), 3000);
    });
}

export default Dashboard;

The DynamicModuleRoutes component dynamically loads route modules. Everything renders correctly, but the loader function isn't being triggered. The console.log("Loader executed") never appears.

Any ideas on why the loader isn't executing or how to debug this further?


Solution

  • Issue

    The issue here is that the code isn't rendering the "dynamic" routes/components as nested routes, but instead they are rendered as descendent routes.

    Nested Routes:

    DescendentRoutes:

    DynamicModuleRoutes is a component rendering descendent routes.

    <Route
      path=":defaultModule/*" // <-- trailing splat matcher
      element={<DynamicModuleRoutes />}
    />
    
    const DynamicModuleRoutes = () => {
      const defaultModule = 'retail'; // or from localStorage
      return (
        <React.Suspense fallback={<div>Loading {defaultModule} module...</div>}>
          <ModuleRoutes />
        </React.Suspense>
      );
    };
    
    const MainRoutes = () => (
      <Routes> {/* <-- descendent Routes wrapper */}
        {/* descendent Routes */}
        <Route
          index
          element={<DashboardPage />}
          loader={dashboardLoader}
          errorElement={<div>Error loading Dashboard</div>}
        />
        <Route path="invoice" element={<div>Invoices Page</div>} />
        <Route path="*" element={<div>Unknown retail page</div>} />
      </Routes>
    );
    

    Route loaders only work in Data Routers, sure, but the caveat is that the routes must be known at declaration time. They can not be dynamically "loaded" in later at runtime.

    Solution Suggestion

    I suspect you could refactor the code a bit to convert the descendent routes into actual nested routes so any lazily loaded components' loaders/actions/etc can work with the Data router.

    Update DynamicModuleRoutes to render an Outlet instead of the child route component.

    const DynamicModuleRoutes = () => {
      const defaultModule = 'retail'; // or from localStorage
      return (
        <React.Suspense fallback={<div>Loading {defaultModule} module...</div>}>
          <Outlet />
        </React.Suspense>
      );
    };
    
    const router = createBrowserRouter(
      createRoutesFromElements(
        <>
          <Route element={<ProtectedRoute />}>
            <Route path="workplace" element={<WorkplaceLayout />}>
              <Route
                path=":defaultModule"
                element={<DynamicModuleRoutes />}
              >
                {/* Lazily loaded route(s) */}
                <Route index lazy={() => import("./pages/Dashboard")} />
    
                {/* Regularly loaded route(s) */}
                <Route path="invoice" element={<div>Invoices Page</div>} />
                <Route path="*" element={<div>Unknown retail page</div>} />
              </Route>
              {/*  other routes */}
            </Route>
          </Route>
          <Route
            path="login"
            element={<LoginPage />}
            errorElement={<ErrorPage />}
          />
          <Route path="*" element={<div>Unknown page</div>} />
        </>
      )
    );
    

    Update the Dashboard file to export specifically named Component, loader, ErrorBoundary/errorElement, and anything else that the Route consumes as a prop (e.g. action, etc).

    Dashboard.jsx

    import { useLoaderData } from "react-router-dom";
    
    export function Component() {
      const data = useLoaderData();
    
      return <div>Dashboard Data: {data}</div>;
    };
    
    export function ErrorBoundary() {
      return <div>Error loading Dashboard</div>;
    }
    
    export function loader() {
      console.log("Loader executed");
      return new Promise((resolve) => {
        setTimeout(() => resolve("Dashboard data loaded"), 3000);
      });
    }