Upgrading React with micro-frontends

Posted 23 October 2024 · 2 min read


In 2021, we deployed a microfrontend architecture using React 17. We followed suggestions to set react and react-dom as shared singleton modules. This rendered the app in a single React tree. With popular dependencies requiring React 18 and React 19 on the way, we must upgrade React in our micro-frontends. With many teams involved, we must do it in small steps.

Introducing a bridge component

Update 2026: We later open sourced our own microfrontend React bridge package, which you can read about in Open sourcing our microfrontend React bridge. This post describes our initial approach using the @module-federation/bridge-react package.

To render the microfrontend in a separate React tree, we added a "bridge" component wrapper. This would allow us to upgrade React in the microfrontend without affecting the shell.

The @module-federation/bridge-react package provides some helpers to support this approach. Within a micro-frontend, we added a new export-app.tsx file to wrap the entry React component in a bridge:

// src/export-app.tsx
import { createBridgeComponent } from "@module-federation/bridge-react";
import App from "./App";
 
export default createBridgeComponent({
  rootComponent: App,
});
 
export const BRIDGE = true;

We also needed to update the ModuleFederationPlugin to expose this new entry file. We had to stop sharing react and react-dom as singletons to upgrade to a higher version than the shell.

// webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "app",
      filename: "remoteEntry.js",
      exposes: {
        "./App": "./src/export-app",
      },
      shared: ["react", "react-dom"],
    }),
  ],
};

In our shell application, we use import-remote to dynamically load remoteEntry files. We need to adjust this to load both directly exposed and bridged React components for backwards compatibility:

import { createRemoteComponent } from '@module-federation/bridge-react';
 
const SomeRemote = React.lazy(async () => {
  const remote = await importRemote({
    url: '/bundles/remote-app',
    scope: 'remote-app',
    module: 'App',
  });
 
  if (remote.BRIDGE) {
    return {
      default: createRemoteComponent({
        loader: () => remote,
        loading: <div>Loading...</div>,
        fallback: (info) =>
          <div>Failed to load {info.error.message}</div>,
      });
    }
 
    return remote;
  }
});

There are a few things going on here:

  • We use importRemote to lazy load the remoteEntry.js file. This avoids loading unnecessary micro-frontends.
  • We check an exported constant, BRIDGE. It tells us if the micro-frontend has been wrapped in a bridge component. This lets teams choose to use the bridge. It keeps compatibility for those who haven't migrated.
  • We return the bridge component in an object with a default property to imitate the shape of a dynamically loaded module that React.lazy expects to receive.

These changes would let us upgrade individual micro-frontends to React 18. We could do this without upgrading the shell. Once all micro-frontends use the bridge approach, we can upgrade the shell without having to upgrade the micro-frontends.


Get new posts by email

Subscribe to get new posts to your inbox, or use the RSS feed with your own feed reader.


Related posts · browse by tag

Dynamically load remoteEntry.js files

Published · 2 min read

Control loading Webpack Module Federation remoteEntry.js files to improve peformance

Using the experimental React Compiler

Published · 4 min read

Trying out the React Compiler with React 19 beta

Delay using a federated module

Published · 2 min read

Ensure federated module has loaded before using it