Avoiding Flash of Loading Component
I've been using React Loadable for a project and now time comes to finally solve the flash of loading component issue. Ended up writing my own loading & fetching strategy.
Motivation
To avoid flash of loading component in split, async loaded pages and components.
Outline
<AsyncModule />
component is responsible for grabbing module from cache then render- fetch module is responsible for loading module and putting in cache
- fetch module returns async resolve result to be "thenable"
- hijack router / links / navigation to
fetch().then()
to make it navigate only after the component is loaded
Gotchas
How to wait
The recommended solution for flash of loading component by React Loadable is to wait certain amount of time. At the end of the day, it's a navigation strategy, not a loading strategy. My understanding is that the best a loading library can do is to provide thenable loader so we can then integrate with navigation strategy to accomplish the load - then - navigate UI.
State management in React?
My original implementation had a complete state management live inside React / context with useReducer
and stuff. That forced fetchModule
to be a hook, which then cannot be called by navigate
directly (you'd have to do something like const fetchThenNavigate = useFetchModule()
and then use it later on). So I moved everything out from the React world until right before rendering the component. I'm using a mutating Map
as well as an object bound to window
and nobody has biten me yet.
Code
Key component here:
import * as React from "react";
export type ModuleType = {
default: any;
[namedExports: string]: any;
};
type Loader = () => Promise<ModuleType>;
const modules: Map<string, ModuleType> = new Map();
// night owl palette
const commonLogStyles = `padding: .5em; border-radius: .25em; background: #011627;`;
export const fetchModule = async (path: string, customLoader?: Loader) => {
if (modules.get(path)) {
console.info(
`%cModule ${path} already loaded`,
`color: #82aaff;${commonLogStyles}`
);
return modules.get(path);
}
const loader = customLoader ? customLoader : window.loaderMap[path];
if (!loader) {
throw new Error(`Didn't see a loader`);
}
console.log(`%cLoading ${path}`, `color: #addb67;${commonLogStyles}`);
return loader()
.then((module: any) => {
console.info(`%c${path} loaded`, `color: #22da6e;${commonLogStyles}`);
modules.set(path, module);
return module;
})
.catch((err: string) => {
console.error(err);
});
};
const AsyncModule = ({
path,
loader,
CustomRender,
children
}: {
path: string;
loader?: Loader;
CustomRender?: React.ComponentType<{ module: any }>;
children?: React.ReactChild;
}) => {
const [module, setModule] = React.useState(modules.get(loader));
React.useEffect(() => {
if (!module) {
fetchModule(path, loader).then(mod => {
setModule(mod);
});
}
}, [loader, path, module, setModule]);
if (!module) {
// this would result in a flickering if the module is not present
return null;
}
const { default: Component, ...rest } = module;
return CustomRender ? (
// pages normally require a custom render such as wrapping with a layout
<CustomRender module={module}>{children}</CustomRender>
) : (
// components normally render directly
<Component {...rest}>{children}</Component>
);
};
export default AsyncModule;