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

CodeSandbox

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;