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 theremoteEntry.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 thatReact.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.