Microfrontends ( React Example)

obaldthepedro

Pedro Loureiro

Posted on May 4, 2022

Microfrontends ( React Example)

Microservices
In recent years, microservices have exploded in popularity, with many companies using this architectural methodology to avoid the limitations of large, monolithic backends. While much has been written about this style of building server-side software, many companies continue to struggle with monolithic frontend codebases.
Perhaps you want to build a progressive or responsive web application, but can't find an easy place to start integrating these features into the existing code. Maybe you just want to scale your development so that multiple teams can work on a single product simultaneously, but the architecture style in the existing monolith means that everyone is stepping on each other's toes. These are all real problems that can all negatively affect your ability to efficiently deliver high quality experiences to your customers.

What is a microfrontend? 
Lately we are seeing more and more attention being paid to the overall architecture and organisational structures that are necessary for complex, modern web development. In particular, we're seeing patterns emerge for decomposing frontend monoliths into smaller, simpler chunks that can be developed, tested and deployed independently, while still appearing to customers as a single cohesive product. We call this technique micro frontends.

Image description

How to make the microfrontend architecture possible?
So, with webpack 5 and Module Federation introduction it came possible to share components across applications.
Module Federation aims to solve the sharing of modules in a distributed system, by shipping those critical shared pieces as macro or as micro as you would like. It does this by pulling them out of the the build pipeline and out of your apps. 
Imagine you manage three teams, each responsible for their own microfrontend. Each of these teams work with different frameworks (Angular,React,Vue). Module Federation allows for these teams to integrate their pieces of code with no problems. Despite the code being possible to be shared using module federation, the state management across applications is not possible, to the rescue comes React useContext, Redux, Zustand, or any other state management library.

There are two concepts in the microfrontend architecture to know: Remote and Consumer

  • Remote exposes a component/components.
  • Consumer consumes the component shared by the remote.

To understand how the logic of a microfrontend works,let's try it using React.

For this article we will create two Remote applications (User and Product) and a Consumer called Shell that will pull components exposed by those two Remote applications.

Step 1 - Create the applications
npx create-react-app shell
npx create-react-app user
npx create-react-app product

Step 2 — Bootstraping
Let’s add a file named bootstrap.js under the src folder and its content will be as follows:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(
  <App />,
  document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

Let’s change the content of the index.js file under the src folder as follows:
import('./bootstrap');
We do these operations so that we can make asynchronous installation. Let’s repeat these operations in the other applications.

Step 3 — App.js component and App.css
Replace the content of App.js in each of the applications,and change the respective name and classnames.

  • App.js
import React from 'react';
import './App.css';

const App = () => (
  <div className="shell-app">
    <h2>Hi from Shell App</h2>
  </div>
);
export default App;
Enter fullscreen mode Exit fullscreen mode
  • App.css
.shell-app {
  margin: 5px;
  text-align: center;
  background: #fff3e0;
  border: 1px dashed #ffb74d;
  border-radius: 5px;
  color: #115cce;
}

Enter fullscreen mode Exit fullscreen mode

Step 3 - Webpack Configuration
As I mentioned before,webpack allows us to use module federation but for us to consume components or expose components to be used by other applications. For that we need to create and configure a webpack.config.js in the root folder of each application as the following:

  • Shell App

With this configuration the Shell App will be able to consume whatever component is exposed from both User and Product apps.
Note: The remote property in the configuration underneath tells us where we should point to if we want to consume components from external applications.

const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const deps = require('./package.json').dependencies;

module.exports = {
  mode: 'development',
  devServer: {
    port: 3000,
  },
  module: {
    rules: [
      {
        test: /\.js?$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env', '@babel/preset-react'],
        },
      },
      {
        test: /\.css$/i,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'SHELL',
      filename: 'remoteEntry.js',
      remotes: {
        PRODUCT: 'PRODUCT@http://localhost:3002/remoteEntry.js',
        USER: 'USER@http://localhost:3001/remoteEntry.js',
      },
      shared: [
        {
          ...deps,
          react: { requiredVersion: deps.react, singleton: true },
          'react-dom': {
            requiredVersion: deps['react-dom'],
            singleton: true,
          },
        },
      ],
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
};

Enter fullscreen mode Exit fullscreen mode
  • User App

This configuration exposes the component App.js from the User application.
Note: The exposes property in the configuration underneath tells webpack that we are exposing the App.js component which can be used by any external application using module federation.
The name is also important to pay attention to as the Shell app webpack.config.js will need the name in the remotes property for it to consume from this application.
Example: remotes: {
PRODUCT: 'PRODUCT@http://localhost:3002/remoteEntry.js',
USER: 'USER@http://localhost:3001/remoteEntry.js',
},

const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const deps = require('./package.json').dependencies;

module.exports = {
  mode: 'development',
  devServer: {
    port: 3001,
  },
  module: {
    rules: [
      {
        test: /\.js?$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env', '@babel/preset-react'],
        },
      },
      {
        test: /\.css$/i,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'USER',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App',
      },
      shared: [
        {
          ...deps,
          react: { requiredVersion: deps.react, singleton: true },
          'react-dom': {
            requiredVersion: deps['react-dom'],
            singleton: true,
          },
        },
      ],
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
};

Enter fullscreen mode Exit fullscreen mode
  • Product App This configuration exposes the component App.js from the Product application.
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const deps = require('./package.json').dependencies;

module.exports = {
  mode: 'development',
  devServer: {
    port: 3002,
  },
  module: {
    rules: [
      {
        test: /\.js?$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env', '@babel/preset-react'],
        },
      },
      {
        test: /\.css$/i,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'PRODUCT',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App',
      },
      shared: [
        {
          ...deps,
          react: { requiredVersion: deps.react, singleton: true },
          'react-dom': {
            requiredVersion: deps['react-dom'],
            singleton: true,
          },
        },
      ],
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
};

Enter fullscreen mode Exit fullscreen mode

If we run yarn webpack server on each of the applications we see the respective applications components rendered.

Shell App
Image description

Product App
Image description

User App
Image description

Despite all the configuration we set for us to expose and consume components from different applications we are not yet seeing the components from the User and Product App being consumed in the Shell App.
For us to do so we need to do a final adjustment to our App.js in the Shell App.

import React from 'react';
import './App.css';

const ProductApp = React.lazy(() => import('PRODUCT/App'));
const UserApp = React.lazy(() => import('USER/App'));

const App = () => (
  <div className="shell-app">
    <h2>Hi from Shell App</h2>

    <React.Suspense fallback="Loading...">
      <ProductApp />
      <UserApp />
    </React.Suspense>
  </div>
);

export default App;

Enter fullscreen mode Exit fullscreen mode

For us to render the consumed components we need to use React.lazy, if you tried to import the components in the shell app as you would on a normal react application, it would not work. Lazy loading allows us to render the components on demand.

If we now run yarn webpack server and look into Shell application we see now that the components are being consumed :)

Image description

For more information on why React.lazy is important you can check this blog (https://blog.logrocket.com/lazy-loading-components-in-react-16-6-6cea535c0b52/)

Important Note: As you saw it can be interesting to use module federation,but for example, if one of the applications crash, then your Shell App will crash. You might think something is wrong with module federation,but one thing we can do to prevent this of occurring is to split our files in our root folder, and create a component for each of the applications that are being consumed. With the help of getDerivedStateFromError() lifecycle method we can set an hasError to be true in our ProductWrapper state if for example the Lazy Load doesn't work and use it accordingly to render a custom message (Product component threw an error!)

Product.js

import React from 'react';
import './App.css';

const ProductApp = React.lazy(() =>
  import('PRODUCT/App').catch((error) => errorLoading(error))
);

export const errorLoading = (err) => {
  console.log('An error has occured', 'Please refresh the page.');
};

class ProductWrapper extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch() {}

  render() {
    if (this.state.hasError) {
      return (
        <React.Suspense fallback={<div>Loading fallback header</div>}>
          <div className="product-app">
            <h2>Product component threw an error!</h2>
          </div>
        </React.Suspense>
      );
    }

    return (
      <>
        <React.Suspense fallback={<div>Header loading</div>}>
          <ProductApp />
        </React.Suspense>
      </>
    );
  }
}

const Product = () => (
  <div>
    <ProductWrapper />
  </div>
);

export default Product;

Enter fullscreen mode Exit fullscreen mode

User.js

import React from 'react';
import './App.css';

const UserApp = React.lazy(() =>
  import('USER/App').catch((error) => errorLoading(error))
);

export const errorLoading = (err) => {
  console.log('An error has occured', 'Please refresh the page.');
};

class UserWrapper extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch() {}

  render() {
    if (this.state.hasError) {
      return (
        <React.Suspense fallback={<div>Loading fallback header</div>}>
          <div className="user-app">
            <h2>User component threw an error! </h2>
          </div>
        </React.Suspense>
      );
    }

    return (
      <>
        <React.Suspense fallback={<div>Header loading</div>}>
          <UserApp />
        </React.Suspense>
      </>
    );
  }
}

const User = () => (
  <div>
    <UserWrapper />
  </div>
);

export default User;


Enter fullscreen mode Exit fullscreen mode

Finally,as we created files for each of the consumed components, we need to adjust the App.js aswell to import these files. So replace everything you had before with this:

App.js

import React from 'react';
import './App.css';

const UserApp = React.lazy(() => import('./User'));

const Product = React.lazy(() => import('./Product'));

class HeaderWrapper extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  render() {
    return (
      <>
        <React.Suspense fallback={<div>Header loading</div>}>
          <Product />
          <UserApp />
        </React.Suspense>
      </>
    );
  }
}

const App = () => (
  <div className="shell-app">
    <h2>Hi from Shell App</h2>
    <HeaderWrapper />
  </div>
);

export default App;

Enter fullscreen mode Exit fullscreen mode

Now let's say we have all the applications running except the Product, when we check the Shell application we can see that despite not having the consumed component it throws an error in the same place we would have the real Product component.

Image description

💖 💪 🙅 🚩
obaldthepedro
Pedro Loureiro

Posted on May 4, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related