Upgrading React with micro-frontends

Posted 23 October 2024


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

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.


Related posts

Dynamically load remoteEntry.js files

Published

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

Using the experimental React Compiler

Published

Trying out the React Compiler with React 19 beta

Delay using a federated module

Published

Ensure federated module has loaded before using it


Thanks for reading

I'm Alex O'Callaghan and this is my personal website where I write about software development and do my best to learn in public. I currently work at Mintel as a Principal Engineer working primarily with React, TypeScript & Python.

I've been leading one of our platform teams maintaining a collection of shared libraries, services and a micro-frontend architecture.

I'm from London and you can find me on a few different social media platforms: