17

I'm using React router to render my different pages according to a specific url. Now I wanted to use React.lazy to lazy load all my page components:

import React from "react";

import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom";

const Page = React.lazy(() => {
  return import("./Page");
});

const App = () => {
  return (
    <Router>
      <React.Suspense fallback={<div>Loading page...</div>}>
        <Switch>
          <Route exact path="/">
            <h1>Home</h1>
            <Link to="/page">Go to another page</Link>
          </Route>

          <Route path="/page" component={Page} />
        </Switch>
      </React.Suspense>
    </Router>
  );
};

export default App;

This work really good but when I go to /page, all my Home disappear and I only see the fallback Loading page... component (the page disappears then another one appears quickly which is disturbing for a user).

That's the default behaviour of React.Suspense but is there a way in this case to keep the actual Home page rendered on the screen with the Loading page... message at the top, and when the Page component is loaded, just render it and replace the Home page?

Edit happy-cohen-z60b9

johannchopin
  • 13,720
  • 10
  • 55
  • 101

3 Answers3

6

This is currently only possible in the experimental Concurrent Mode. You will have to wait until this becomes generally available before using this in a production site.

Mordechai
  • 15,437
  • 2
  • 41
  • 82
2

Good day, I believe your aim could be succeded using react-router v6: loader

Also, good article from official docs about Deferred Data Guide

The explanation contains from two parts:

  1. First part: The core idea is that the loader function could be async and if you await your fetch data inside the component chunk do not trigger and will wait for this data before calling the element in router config.

So this allows you "keep the current page rendered until the new page is loaded"

  1. Second part: But if you remove await and return a new promise as a response your lazy component chunk starts loading simultaneously with data fetching.

So this allows you to show UI (not the blank screen) and loader while data retrieving in the process

Conclusion:

So my idea is that for an initial load, we use the "second part", so load chunk with fetch in parallel, and for the following enters to this route load in loader needed chunks + fetch data in parallel and await both in loader before going to component

Let's deep into the example:

  1. We have a router with an async loader that for the initial load creates a fetch promise inside and passes through yourself, so fetch and chunk load simultaneously. And for subsequence loads await fetch and chunks in parallel (Promise.all, Promise.allSettled)
    import { defer } from 'react-router-dom';

    // store if it is initial load
    let isInitialLoad = true;
    
    const loaderDetails = async ({ request, params }) => {
      const { yourParam } = params;
    
      if (!yourParam) {
        throw new Error('Error');
      }
    
      const responsePromise = fetch(
        `url/${yourParam}`, 
        {
          signal: request.signal,
        },
      ).then((res) => res.json());
    
      if (isInitialLoad) {
       isInitialLoad = false;
    
       return defer({ responsePromise });
      }

      // For subsequences calls we load all chunks + fetch data and await for result before further transfer
      const [response] = await Promise.all([
          responsePromise,
          // ... other chunks
        ]);
    
        return {
          responsePromise: response,
        };
    };
  1. Set this loader to the router config:
    const ROUTES = createBrowserRoutes({
     {
      path: '*',
      element: () => import('YourContainerComponent'),
      
      /**
      * FYI: loader could be sync or async 
      *  so we can apply here dynamic import
      *  This solves the problem when we have tons of route configurations
      *  and we load loader logic only for a particular route
      * BUT this causes another HUGE problem:
      *  we must wait for dynamic loader import and only then start 
      *  loading lazy component from the element prop.
      *
      * So we should make a call for all needed chunks inside the loader.
      **/
      loader: async (props) => {
        Promise.all(import('loaderDetails'), ...otherChunks);

        const loader = await import('loaderDetails');

        return loader.default(props);
      },

      /* Sync approach doesn't have this problem
      *  BUT have another:
      *  Let's imagine we have 25 pages and load only one
      *   for this case we load all loader logic
      *   let's assume loader size is 20kb so it is 0.5 mb size total
      *   This is huge, we don't need 480kb for displaying our page

      * My conclusion: we should almost always strive to first approach
      */
      // loader: loaderDetails,
    });

and initialize in the App:

    import { Suspense } from 'react';
    import { RouterProvider } from 'react-router-dom';

    /**
    * Suspense need if we assign async requests in ROUTES config
    **/
    const App = () => (
      <Suspense>
        <RouterProvider router={ROUTES} />
      </Suspense>
    );

Retrieve this data in a particular component from react-router:

    const YourContainerComponent = () => {
      const loaderResponse = useLoaderData(); // return { responsePromise }
    
      return (
        <Suspense>
          <Await
            key={'UNIQUE_KEY_PROP_IDENTIFIER'}
            resolve={loaderResponse.responsePromise}
            errorElement={'some error'}
          >
            <YourComponent />
          </Await>
        </SuspenseDefault>
      )
    }
    
    const YourPresentationComponent = () => {
      // get data direct here
      // component wait for retrieved data
      // while he waiting on UI shows a fallback property from <Suspense fallback={undefined}>
      const loaderData = useAsyncValue(); 
    
      return (
        <>
         Show data: {JSON.stringify(loaderData)}
        </>
      );
    }

    export default YourContainerComponent;

In addition to showing loader I propose creating a global loader for it, we should play around with it:

  1. For showing the global loader we can create a component and get the state of loading and show the loader if the state === 'loading'
    const GlobalLoader = () => {
      const { state } = useNavigation();
      const isEnabled = state === 'loading';

      if (isEnabled) {
        return state;
      }

      return null;
    };

Hope it helps you and pushes for great solutions

Andrew
  • 79
  • 1
  • 6
  • What does it mean `import('loaderDetails')` - why do you mean by importing "string"? – Tal Rofe Mar 11 '23 at 15:13
  • @TalRofe This is a dynamic import: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import Let's assume we had 100 routes and everyone has a loader around 20kb in size. Our config size increased to + 100*20 = ~2MB. So, as a solution, we can load "loader" dynamically as a chunk and keep our routes config as less as possible. But this solution causes a waterfall network problem. First should load the "loader" chunk, then perform inner logic like "fetch" data. We should be aware of it and use it in less visited routes. – Andrew Mar 30 '23 at 16:28
0

You can use this package to archive the current page as a fallback.

Read instructions for more details Link.

Asif vora
  • 3,163
  • 3
  • 15
  • 31