Keep the current page rendered until the new page is loaded with React.lazy and React.Suspense

4.4k views Asked by At

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

3

There are 3 answers

3
Mordechai On

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.

1
Asif vora On

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

Read instructions for more details Link.

2
Andrew On

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