React: swap a parent component without remounting the child component?

Viewed 265

I have a wrapper component that I want to load dynamically because it uses hooks with large file size, but I can't code-split hooks directly. While the component is loading, I show a plain div instead. Once it has loaded, I swap it in.

E.g. the component tree looks like:

<WrapperDeferred>
  <Child />
</WrapperDeferred>

WrapperDeferred looks something like:

let Wrapper;

function WrapperDeferred({ children }) {
  const forceUpdate = useForceUpdate();

  useEffect(() => {
    if (!Wrapper) {
      import('./Wrapper').then(module => {
        Wrapper = module.default;
        forceUpdate();
      });
    }
  }, []);

  return Wrapper ? <Wrapper>{children}</Wrapper> : <div>{children}</div>;
}

Wrapper just renders a div, so the HTML structure remains the same. The only difference is that Wrapper uses a bunch of hooks.

The issue is that after Wrapper loads, the child component unmounts, rerenders, and remounts. Is there a way to swap <div> to <Wrapper> without rerendering the child component? The child component already has React.memo.

2 Answers

You can do something like this with Webpack lazy-loading (†):

import * as React from "react";
import PropTypes from "prop-types";

const DynamicImport = ({ children, loadFile }) => {
  const isMounted = React.useRef();
  const [state, setState] = React.useState({ Component: null });
  const { Component } = state;

  const importFile = React.useCallback(async () => {
    try {
      const { default: file } = await loadFile();

      if (isMounted && isMounted.current) setState({ Component: file });
    } catch (err) {
      console.error(err.toString());
    }
  }, [loadFile]);

  React.useEffect(() => {
    importFile();
  }, [importFile]);

  return (
    <span ref={isMounted}>
      {Component ? <Component>{children}</Component> : <div>{children}</div>}
    </span>
  );
};

DynamicImport.propTypes = {
  loadFile: PropTypes.func.isRequired,
  children: PropTypes.node
};

export default DynamicImport;

Then use it like so:

import * as React from "react";
import DynamicImport from "./components/DynamicImport";

const Example = () => (
  <DynamicImport
    loadFile={() =>
      import(
        /* webpackMode: "lazy" */
        /* webpackChunkName: "large-component" */
        "./components/LargeComponent"
      )
    }
  >
    Hello world
  </DynamicImport>
);

export default Example;

Working Example:

Edit Dynamic Lazy Loading

Demo: https://bo8p7.csb.app/


† The major downside to this approach is that Webpack won't be able to tree-shake the component's dependencies. As a result, the entire 3rd party library/libraries (and ALL of their dependencies) will be added to the production bundle. Check this similar question for a more in depth explanation.

That said, you can reduce the production build a bit by creating custom Webpack splitChunks rules and importing 3rd party components from their parent directories...

for example:

import Table from "antd/lib/table";

instead of

import { Table } from "antd";

There is no way in prod mode to change root node without unmount children nodes.

https://reactjs.org/docs/reconciliation.html#elements-of-different-types

in source code

      if (
        current.elementType === elementType ||
        // Keep this check inline so it only runs on the false path:
        (__DEV__
          ? isCompatibleFamilyForHotReloading(current, element)
          : false) ||

You can do lazy hook wrapper. Something like this

export const useHookWrapper = (hook, ...deps) => {
  const [result, setResult] = useStore([])

  useEffect(() => {
    if (hook) {
      setResult(hook(...deps))
    }
  }, [hook, ...deps])

  return result
}


let lazyModule;

const Wrapper = () => {
  const [module, setModule] = useState()
    ...

  const [props, callback] = useHookWrapper(module?.hook, a, b, c)
    ...
  useEffect(() => {
    if (!lazyModule) {
      import('./Wrapper').then(module => {
        lazyModule = module.default;
        setModule(lazyModule)
      });
    } else {
      setModule(lazyModule)
    }
  }, []);

  ...
}
Related