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>