Reputation: 1807
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);
});
Upvotes: 66
Views: 49368
Reputation: 1303
My issue seems to be very similar to this. In App.jsx
we have this code construct:
<React.Suspense fallback={<Loading />}>
</React.Suspense>
What is happening is that when a user changes pages, there's a noticeable blank screen and then the loading indicator appears a little after. With slow 3G connections, it's quite noticeable. So much so, that we will likely have to give up Code Splitting if this can't be resolved.
My question is simple: How can we force our Loading Indicator component to be instantly shown on top of the blank screen without any delay?
Upvotes: 0
Reputation: 843
If you are experiencing a screen flickering issue (Fallback screen gets visible and gets disappeared in split of second due to the fast loading of the next component) you can solve it by adding a startTransition.
Eg:- If you have lazy loaded a modal component, and if you are making it appear by calling an state action named 'setOpenModal', you can call it inside a startTransition function like below to avoid the flickering issue.
import { startTransition } from 'react';
function ComponentA() {
const [openModal, setOpenModal] = useState(false);
const modalHandler = () => {
startTransition(() => {
setOpenModal(!openModal)
});
}
}
You can find more details about this using below react documentation.
https://react.dev/reference/react/Suspense#preventing-already-revealed-content-from-hiding
Upvotes: 1
Reputation: 1
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")));
Upvotes: 0
Reputation: 21
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
Upvotes: 0
Reputation: 163
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);
});
Upvotes: 3
Reputation: 1195
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)
Upvotes: 3
Reputation: 11
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;
Upvotes: 1
Reputation: 74490
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:
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).
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>
1.) useSuspenseAnimation
Hook returns three values:
hasImportFinished
(boolean
) → if true
, Fallback
can start its fade out animationenableComponent
(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.
Upvotes: 9
Reputation: 4721
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),
);
});
Upvotes: 0
Reputation: 331
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>
Upvotes: 17
Reputation: 222309
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);
});
Upvotes: 99
Reputation: 616
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.
Upvotes: 2