60

I am trying to use the new React Lazy and Suspense to create a fallback loading component. This works great, but the fallback is showing only a few ms. Is there a way to add an additional delay or minimum time, so I can show animations from this component before the next component is rendered?

Lazy import now

const Home = lazy(() => import("./home"));
const Products = lazy(() => import("./home/products"));

Waiting component:

function WaitingComponent(Component) {

    return props => (
      <Suspense fallback={<Loading />}>
            <Component {...props} />
      </Suspense>
    );
}

Can I do something like this?

const Home = lazy(() => {
  setTimeout(import("./home"), 300);
});
vemund
  • 1,667
  • 4
  • 29
  • 43
  • 2
    Why would you want to do something like that? The user wants to use your content and app and not look at loading spinners. The whole reason lazy and Suspense was introduced is for you as a developer to give the user a better UX, by reducing the initial app loading time and not showing loading spinners everywhere. What you are trying to do is the opposite of that idea. – Johannes Klauß Jan 12 '19 at 11:27
  • 3
    @JohannesKlauß You're talking about *initial* loading time. Suspense is obviously intended for spinners and other loading indicators for lazily loaded components. That's why it has `fallback`. – Estus Flask Jan 12 '19 at 11:30
  • 11
    @JohannesKlauß I don't think you understand my UX problem. My goal is not to create a one minute long spinning loader, my goal is to create a better loading function that is smooth and beautiful. It's a growing trend in UX to offer the user better page transitions. – vemund Jan 12 '19 at 11:31
  • @JohannesKlauß The idea with Suspense/Lazy is also to reduce bundle size, by only loading required component. Here, the goal is not to increase loading time, but to have a smoother transition between routes. – Thibault Boursier Mar 01 '21 at 11:22

10 Answers10

88

lazy function is supposed to return a promise of { default: ... } object which is returned by import() of a module with default export. setTimeout doesn't return a promise and cannot be used like that. While arbitrary promise can:

const Home = lazy(() => {
  return new Promise(resolve => {
    setTimeout(() => resolve(import("./home")), 300);
  });
});

If an objective is to provide minimum delay, this isn't a good choice because this will result in additional delay.

A minimum delay would be:

const Home = lazy(() => {
  return Promise.all([
    import("./home"),
    new Promise(resolve => setTimeout(resolve, 300))
  ])
  .then(([moduleExports]) => moduleExports);
});
Grgur
  • 7,092
  • 2
  • 19
  • 34
Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • Can you please explain why the first code will not work? setTimeout will resolve the promise after 300 mils. Thanks – Adi Azarya May 21 '19 at 14:46
  • 11
    @AdiAzarya It will work but not as requested. A delay will be *loading time plus extra 300 ms*, not *loading time or at least 300 ms*. – Estus Flask May 21 '19 at 14:55
  • 5
    Another approach may be to use `Promise.all([import(...), delayPromise])`, which executes the importing and the delay in parallel and also is easier to understand. – Palle Apr 21 '20 at 22:59
  • Unfortunately, this answer (well, part one of it) doesn't work in a Typescript context; fortunately, there's https://stackoverflow.com/questions/62594862/typescript-react-lazy that works with Typescript for part 1, the additional delay to be introduced. – TheDiveO Dec 22 '20 at 14:43
  • Beautiful, works like a charm for testing my loading spinners! :) – Kim Skogsmo Mar 23 '22 at 11:47
16

As mentioned by loopmode, component fallback should have a timeout.

import React, { useState, useEffect } from 'react'

const DelayedFallback = () => {
  const [show, setShow] = useState(false)
  useEffect(() => {
    let timeout = setTimeout(() => setShow(true), 300)
    return () => {
      clearTimeout(timeout)
    }
  }, [])

  return (
    <>
      {show && <h3>Loading ...</h3>}
    </>
  )
}
export default DelayedFallback

Then just import that component and use it as fallback.

<Suspense fallback={<DelayedFallback />}>
       <LazyComponent  />
</Suspense>
Akrom
  • 311
  • 2
  • 5
  • This does not help. Still, the fallback loader appears just for a short period of time. Is there any way to make it last longer because even after the loader disappears it is taking quite a long time for the component to load using react Route? – sareek Nov 18 '20 at 11:12
9

Fallback component animations with Suspense and lazy

@Akrom Sprinter has a good solution in case of fast load times, as it hides the fallback spinner and avoids overall delay. Here is an extension for more complex animations requested by OP:

1. Simple variant: fade-in + delayed display

const App = () => {
  const [isEnabled, setEnabled] = React.useState(false);
  return (
    <div>
      <button onClick={() => setEnabled(b => !b)}>Toggle Component</button>
      <React.Suspense fallback={<Fallback />}>
        {isEnabled && <Home />}
      </React.Suspense>
    </div>
  );
};

const Fallback = () => {
  const containerRef = React.useRef();
  return (
    <p ref={containerRef} className="fallback-fadein">
      <i className="fa fa-spinner spin" style={{ fontSize: "64px" }} />
    </p>
  );
};

/*
 Technical helpers
*/

const Home = React.lazy(() => fakeDelay(2000)(import_("./routes/Home")));

// import_ is just a stub for the stack snippet; use dynamic import in real code.
function import_(path) {
  return Promise.resolve({ default: () => <p>Hello Home!</p> });
}

// add some async delay for illustration purposes
function fakeDelay(ms) {
  return promise =>
    promise.then(
      data =>
        new Promise(resolve => {
          setTimeout(() => resolve(data), ms);
        })
    );
}

ReactDOM.render(<App />, document.getElementById("root"));
/* Delay showing spinner first, then gradually let it fade in. */
.fallback-fadein {
  visibility: hidden;
  animation: fadein 1.5s;
  animation-fill-mode: forwards;
  animation-delay: 0.5s; /* no spinner flickering for fast load times */
}

@keyframes fadein {
  from {
    visibility: visible;
    opacity: 0;
  }
  to {
    visibility: visible;
    opacity: 1;
  }
}

.spin {
  animation: spin 2s infinite linear;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(359deg);
  }
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<link
  rel="stylesheet"
  href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
/>
<div id="root"></div>

You just add some @keyframes animations to Fallback component, and delay its display either by setTimeout and a state flag, or by pure CSS (animation-fill-mode and -delay used here).

2. Complex variant: fade-in and out + delayed display

This is possible, but needs a wrapper. We don't have a direct API for Suspense to wait for a fade out animation, before the Fallback component is unmounted.

Let's create a custom useSuspenseAnimation Hook, that delays the promise given to React.lazy long enough, so that our ending animation is fully visible:

// inside useSuspenseAnimation
const DeferredHomeComp = React.lazy(() => Promise.all([
    import("./routes/Home"), 
    deferred.promise // resolve this promise, when Fallback animation is complete
  ]).then(([imp]) => imp)
)

const App = () => {
  const { DeferredComponent, ...fallbackProps } = useSuspenseAnimation(
    "./routes/Home"
  );
  const [isEnabled, setEnabled] = React.useState(false);
  return (
    <div>
      <button onClick={() => setEnabled(b => !b)}>Toggle Component</button>
      <React.Suspense fallback={<Fallback {...fallbackProps} />}>
        {isEnabled && <DeferredComponent />}
      </React.Suspense>
    </div>
  );
};

const Fallback = ({ hasImportFinished, enableComponent }) => {
  const ref = React.useRef();
  React.useEffect(() => {
    const current = ref.current;
    current.addEventListener("animationend", handleAnimationEnd);
    return () => {
      current.removeEventListener("animationend", handleAnimationEnd);
    };

    function handleAnimationEnd(ev) {
      if (ev.animationName === "fadeout") {
        enableComponent();
      }
    }
  }, [enableComponent]);

  const classes = hasImportFinished ? "fallback-fadeout" : "fallback-fadein";

  return (
    <p ref={ref} className={classes}>
      <i className="fa fa-spinner spin" style={{ fontSize: "64px" }} />
    </p>
  );
};

/* 
Possible State transitions: LAZY -> IMPORT_FINISHED -> ENABLED
- LAZY: React suspense hasn't been triggered yet.
- IMPORT_FINISHED: dynamic import has completed, now we can trigger animations.
- ENABLED: Deferred component will now be displayed 
*/
function useSuspenseAnimation(path) {
  const [state, setState] = React.useState(init);

  const enableComponent = React.useCallback(() => {
    if (state.status === "IMPORT_FINISHED") {
      setState(prev => ({ ...prev, status: "ENABLED" }));
      state.deferred.resolve();
    }
  }, [state]);

  return {
    hasImportFinished: state.status === "IMPORT_FINISHED",
    DeferredComponent: state.DeferredComponent,
    enableComponent
  };

  function init() {
    const deferred = deferPromise();
    // component object reference  is kept stable, since it's stored in state.
    const DeferredComponent = React.lazy(() =>
      Promise.all([
        // again some fake delay for illustration
        fakeDelay(2000)(import_(path)).then(imp => {
          // triggers re-render, so containing component can react
          setState(prev => ({ ...prev, status: "IMPORT_FINISHED" }));
          return imp;
        }),
        deferred.promise
      ]).then(([imp]) => imp)
    );

    return {
      status: "LAZY",
      DeferredComponent,
      deferred
    };
  }
}

/*
technical helpers
*/

// import_ is just a stub for the stack snippet; use dynamic import in real code.
function import_(path) {
  return Promise.resolve({ default: () => <p>Hello Home!</p> });
}

// add some async delay for illustration purposes
function fakeDelay(ms) {
  return promise =>
    promise.then(
      data =>
        new Promise(resolve => {
          setTimeout(() => resolve(data), ms);
        })
    );
}

function deferPromise() {
  let resolve;
  const promise = new Promise(_resolve => {
    resolve = _resolve;
  });
  return { resolve, promise };
}

ReactDOM.render(<App />, document.getElementById("root"));
/* Delay showing spinner first, then gradually let it fade in. */
.fallback-fadein {
  visibility: hidden;
  animation: fadein 1.5s;
  animation-fill-mode: forwards;
  animation-delay: 0.5s; /* no spinner flickering for fast load times */
}

@keyframes fadein {
  from {
    visibility: visible;
    opacity: 0;
  }
  to {
    visibility: visible;
    opacity: 1;
  }
}

.fallback-fadeout {
  animation: fadeout 1s;
  animation-fill-mode: forwards;
}

@keyframes fadeout {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

.spin {
  animation: spin 2s infinite linear;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(359deg);
  }
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<link
  rel="stylesheet"
  href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
/>
<div id="root"></div>

Key points for complex variant

1.) useSuspenseAnimation Hook returns three values:

  • hasImportFinished (boolean) → if true, Fallback can start its fade out animation
  • enableComponent (callback) → invoke it to unmount Fallback, when animation is done.
  • DeferredComponent → extended lazy Component loaded by dynamic import

2.) Listen to the animationend DOM event, so we know when animation has ended.

ford04
  • 66,267
  • 20
  • 199
  • 171
3

props to @Estus Flask for a super helpful answer. I was using just the setTimeout functionality before, but couldn't get tests to work at all. Calling setTimeout in the tests was causing nested calls, jest.useFakeTimers() and jest.runAllTimers() didn't seem to do anything, and I was stuck getting the loader back. Since searching for the right way to test this took so long, I figured it would be helpful share how I was able to test it. With an implementation per the given solution:

import React, { ReactElement, Suspense } from 'react';
import { Outlet, Route, Routes } from 'react-router-dom';

import Loader from 'app/common/components/Loader';


const Navigation = React.lazy(() => {
  return Promise.all([
    import("./Navigation"),
    new Promise(resolve => setTimeout(resolve, 300))
  ])
  .then(([moduleExports]) => moduleExports);
});
const Home = React.lazy(() => {
  return Promise.all([
    import("./Home"),
    new Promise(resolve => setTimeout(resolve, 300))
  ])
  .then(([moduleExports]) => moduleExports);
});

interface PagesProps {
  toggleTheme: () => void;
}

const Pages = (props: PagesProps): ReactElement => (
  <Suspense fallback={<Loader />}>
    <Routes>
      <Route path="/" element={
        <>
          <Navigation toggleTheme={props.toggleTheme}/>
          <Outlet />
        </>
      }>
        <Route index element={<Home />} />
      </Route>
    </Routes>
  </Suspense>
);

export default Pages;

I was able to successfully test it with the following. Note that if you don't include jest.useFakeTimers() and jest.runAllTimers() you'll see flaky tests. There's a little excess detail in my tests because I'm also testing pushing the history (in other tests), but hopefully this helps anyone else!

/**
 * @jest-environment jsdom
 */
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';

import Pages from './';

describe('Pages component', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  })
  const history = createMemoryHistory();

  it('displays loader when lazy', async () => {
    render(
      <Router location={history.location} navigator={history} navigationType={history.action}>
        <Pages toggleTheme={function (): void { return null; } } />
      </Router>,
    );

    const lazyElement = await screen.findByText(/please wait/i);
    expect(lazyElement).toBeInTheDocument();
  });

  it('displays "Welcome!" on Home page lazily', async () => {
    render(
      <Router location={history.location} navigator={history} navigationType={history.action}>
        <Pages toggleTheme={function (): void { return null; } } />
      </Router>,
    );

    const fallbackLoader = await screen.findByText(/please wait/i);
    expect(fallbackLoader).toBeInTheDocument();
    jest.runAllTimers();

    const lazyElement = await screen.findByText('Welcome!');
    expect(lazyElement).toBeInTheDocument();
  });

  afterEach(cleanup);
});
liz
  • 163
  • 1
  • 1
  • 7
2

You should create a fallback component that itself has a timeout and a visible state. Initially you set visible false. When fallback component gets mounted, it should setTimeout to turn visible state flag on. Either make sure your component is still mounted, or clear the timeout when the component gets unmounted. Finally, if visible state is false, render null in your fallback component (or e.g. just blocking/semi-transparent overlay but no spinner/animation)

Then use such component, e.g. <Loading overlay/>, as fallback.

loopmode
  • 617
  • 8
  • 14
  • Can you provide an example? Can we say to the Suspense component that if the fallback component is rendered and mounted, it must stay mounted for e.g. **at least** 1 second? – tonix Nov 08 '19 at 21:37
1

To avoid flashing a loader if the loading is very fast, you could use a p-min-delay function, that delays a promise a minimum amount of time. Useful when you have a promise that may settle immediately or may take some time, and you want to ensure it doesn't settle too fast.

For example:

import { Suspense, lazy } from 'react';
import { PageLoadingIndicator } from 'components';
import pMinDelay from 'p-min-delay';

const HomePage = lazy(() => pMinDelay(import('./pages/Home'), 500));

function App() {
  return (
    <Suspense fallback={<PageLoadingIndicator />}>
      <HomePage />
    </Suspense>
  );
}

export default App;
Night Zen
  • 11
  • 2
1

If anyone is looking for a typescript, abstracted solution:

import { ComponentType, lazy } from 'react';

export const lazyMinLoadTime = <T extends ComponentType<any>>(factory: () => Promise<{ default: T }>, minLoadTimeMs = 2000) =>
  lazy(() =>
    Promise.all([factory(), new Promise((resolve) => setTimeout(resolve, minLoadTimeMs))]).then(([moduleExports]) => moduleExports)
  );

Usage:

const ImportedComponent = lazyMinLoadTime(() => import('./component'), 2000)
rt_
  • 1,105
  • 2
  • 15
  • 25
0

I faced similar problem moreover I was using TypeScript along with React. So, I had to respect typescript compiler as well & I went ahead with an approach having an infinite delay along with no complain from typescript as well. Promise that never resolved

const LazyRoute = lazy(() => {
  return new Promise(resolve => () =>
    import(
      '../../abc'
    ).then(x => x e => null as never),
  );
});
Divyanshu Rawat
  • 4,421
  • 2
  • 37
  • 53
0
let shouldNotDelay = false;

export const DelayLoading = () => {
  if (shouldNotDelay) {
    return null;
  }
  throw new Promise((resolve) => {
    setTimeout(() => {
      shouldNotDelay = true;
      resolve(1);
    }, 2000);
  });
};

Here is full implementation : https://codesandbox.io/s/suspense-delay-7i5b34?file=/src/index.js

  • As it’s currently written, your answer is unclear. Please [edit] to add additional details that will help others understand how this addresses the question asked. You can find more information on how to write good answers [in the help center](/help/how-to-answer). – Community Mar 24 '23 at 08:48
0

You can write a new function that await both the component and a delay. This await Promise.all must wait for both promises to resolve, so the loading time takes at least delayMs thus no more flickering.

export const importDelay = (importFn: () => Promise<any>, delayMs = 500) =>
    async () => {
        const [ result ] = await Promise.all([
            importFn(),
            new Promise((resolve) => setTimeout(resolve, delayMs))
        ]);
        
        return result as { default: ComponentType<any> };
    };

And use it like this:

const Component = React.lazy(importDelay(import("./component")));
Anindya Dey
  • 825
  • 1
  • 8
  • 18