Implementing Micro Frontends Using React
Monolithics — In modern software development, this is something we try to avoid because they have proven to be hard to maintain, refactor, debug, … etc. Instead, the software industry had adopted, Microservice pattern for designing backends.
Even with a well-designed backend, it is common that the frontend of an application may suffer from Monolithic architecture. Frontends are ever-changing and many teams are working in the same codebase, resulting in a complex and hard to maintain frontend app.
To avoid this, the concept of Micro Frontends has been introduced. The article by ThoughtWorks is a very detailed explanation of this architecture. In summary, Micro Frontend will help to break a frontend application into independent pieces managed by separate teams, thereby reducing complexity and with a clear separation of concerns. These mini-apps can be developed, tested, and deployed in isolation and when brought together they will give the full functionality of one logical frontend.
Through this post, I will show you how I built a Micro Frontend application using React.
Let’s dive right in.
Concepts
Micro Frontend — Similar to a Microservice, independently deployable unit of the frontend application. The full application will be a summation of many Micro Frontends. A Micro Frontend will be able to work on its own.
Container App — This a special Micro Frontend in the context of Micro Frontends responsible for integrating the rest of the Micro Frontends so that they work as a single frontend application.
BFF — Backend to Frontend has a special place in the Microservice architecture, it aggregators responses from various Microservices so that frontend sees as if it comes from a single service. Since with Micro Frontends, we partition the frontend into pieces now there can be a separate BFF to each Micro Frontend (if required).
Our application
With those concepts in mind, let’s see the application we are going to build.
It’s a simple Shopping application. We have 2 Micro Frontends,
- Product List app: Contains the orderable product list information
Responsibilities:
- Load products from a server so that the user can order them.
- Allow items to be added to the Cart
With the minimum CSS styling, shown below is the product list where users can order items.
2. Container app: The app which integrates the Product List app.
Responsibilities:
- Loads the Product List app to the main app.
- Stores the Cart object and pass it to the Product List app.
- Show the ordered items from the Product List.
- Provide a link to go to the Product List page
The first thing that would come to your mind when you look into this application is why on earth do we need a Micro Frontend architecture for this? (With the ugly interfaces :D)
Yes, it is a good question. The intention here is to introduce a few ideas which can be used to develop complicated applications.
Let’s see the code, shall we?
To generate each Micro Frontend I used a template project found here. It can be used to create React applications, it will do the bundling with Webpack and NodeJS will serve the static content. (To create a React application from the ground up, you can follow this)
The Product List App
Let’s look at the high-level folder structure.
- components
-productList.js
-styles.js
- public
- main.js
- bundle.js
- index.html- app.js
- index.js
- shopping-list-app.js
- package.json
- webpack.config.js
- .babelrc
- ...
There are a few important directories/files here.
- app.js: This file contains the main React application of the project, this will be bundled as a library using Webpack. (We will see how this is done when looking into
webpack.config.js
file)
import React from 'react';
import ProductList from "./components/productList";
// Exported as a module so that it can be used by the Container app
const ShoppingProductList = (props) => {
return (
<div>
<h2> Product List </h2>
<ProductList {...props}/>
</div>
);
};
export default ShoppingProductList;
The components
directory will contain ProductList components with data loading and rendering parts. We are not going to see implementation because it’s relatively straightforward. (I used a NodeJS/Express server to send sample products list information)
The ShoppingProductList React component will be used as a global in the Container app, therefore, make sure this same name is not repeated elsewhere. (Better to define a naming scheme for Micro Frontends in advance)
Note: When developing Micro Frontend applications, the names of exposed components should be handled very carefully to avoid any conflicts.
2. shopping-list-app.js: This is the entry-level for the standalone Product List application.
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import ShoppingProductList from "./app";
// Set up such that this can act as a stand alone application
const ShoppingProductListWrapper = () => {
const [cart, setCart] = useState({});
return <ShoppingProductList cart={cart} setCart={setCart}/>
};
ReactDOM.render(<ShoppingProductListWrapper />, document.getElementById('shopping-product-list'));
We are passing, Cart
and setCart
as props to the main app so that the application can be run without the container app. This is one important aspect of Micro Frontends; able to run/test in standalone mode.
3. public directory: Contains the static content of the application which will be served by the server.
main.js
: Created by Webpack taking shopping-list-app.js
as the input. index.html
: Serves the standalone application by combining with main.js
bundle.js
: Created by Webapck taking app.js
as the input. The output is a library that exposes the ShoppingProudctList React application used by the Container app.
4. webpack.config.js: Webpack configurations on how to generate main.js
and bundle.js
We have two outputs as here main.js
and bundle.js
const MinifyPlugin = require('babel-minify-webpack-plugin');
module.exports = [{
entry: './app.js',
output: {
path: __dirname + '/public',
filename: 'bundle.js',
library: 'ShoppingProductList',
libraryTarget: 'umd'
},
module: {
rules: [
{ test: /\.js$/, use: { loader: "babel-loader" }, exclude: /node_modules/}
]
},
mode: "production",
plugins: [
new MinifyPlugin()
]},
{
entry: './shopping-list-app.js',
output: {
path: __dirname + '/public',
filename: 'main.js',
},
module: {
rules: [
{ test: /\.js$/, use: { loader: "babel-loader" }, exclude: /node_modules/}
]
},
mode: "production",
plugins: [
new MinifyPlugin()
]
}];
5. index.js: The NodeJS server which serves the static content
const express = require('express');
const app = express();
const PORT = 4000;
// This sends ShoppingProductList library for the Container app
app.get('/manifest', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.sendFile(__dirname + '/public/bundle.js');
});
// For all other API requests, expose public folder
app.use(express.static('public'));
app.get('*', (req, res) => {
res.sendFile(__dirname + '/public/index.html');
});
// App listener
app.listen(PORT, () => {
console.log('App started on port: ', PORT);
});
Note here /manifest
will send the bundled JS relevant to the ShoppingProductList React application to the container (Allow access from any client). If the user requests any other URL, we expose public
directory and sever the index.html
file.
6. package.json: Dependencies and scripts
{
"name": "shopping-product-list",
"version": "1.0.0",
"description": "Product List App",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack",
"start": "webpack && nodejs index.js"
},
"dependencies": {
"express": "^4.17.1",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router-dom": "^5.1.2"
},
"devDependencies": {
"@babel/core": "^7.8.4",
"@babel/preset-env": "^7.8.4",
"@babel/preset-react": "^7.8.3",
"babel-loader": "^8.0.6",
"babel-minify-webpack-plugin": "^0.3.1",
"webpack": "^4.42.0",
"webpack-cli": "^3.3.11"
}
}
version
in package.json will play a key role to identify which version of the Micro Frontend in production at a given time.
There are a few things that should be noted in this section.
See how
- Main React application was defined and it was bundled so that Container app can access it via /manifest
- We have set up the application so that it can be used/tested as a standalone application by wrapping by a higher-order component
- Webpack is used to build the application as a standalone app and bundle the main React application to be used by Container
Take a breath. Next, the container app.
Container App
The folder structure here will be more of the same as the Product List app, but we don’t need a wrapper around the Container app nor manifest file. Therefore those files are not available here.
- components
- index.js
- public
- index.html
- main.js
- app.js
- index.js
- package.json
- webpack.config.js
- .babelrc
- ...
In the index.js
we only have static content serving part only, so the server will be simple.
const express = require('express');
const app = express();
const PORT = 3000;
// Serves the static files
app.use(express.static('public'));
app.get('*', (req, res) => {
res.sendFile(__dirname + '/public/index.html');
});
// App listener
app.listen(PORT, () => {
console.log('App started on port: ', PORT);
});
app.js
will look like,
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import ShoppingApp from './components';
const Main = () => {
return (
<Router>
<ShoppingApp />
</Router>
);
};
ReactDOM.render( <Main />, document.getElementById('root'));
Finally the /component/index.js
containing ShoppingApp
import React, { useEffect, useState } from 'react';
import { Route, Switch, Link } from 'react-router-dom';
import camelize from 'camelize';...const ShoppingApp = () => {
const [cart, setCart] = useState({});
return (
<div>
<Home/>
<Switch>
<Route path="/product-list" component={(props) =>
<ComponentLoader
{...props}
componentName='ShoppingProductList'
cart={cart}
setCart={setCart}
/>}
/>
</Switch>
<Ordered cart={cart}/>
</div>
);
};
export default ShoppingApp;
Obviously there is more code here, but let’s start with the main component. React Hooks are used here.
There are a couple of components to note here.
- <Home /> component: This component will be global to all Micro Frontends (We only have one). It will contain
Links
to other pages.
const Home = () => {
return (
<div>
<h1> Welcome to the Shopping App </h1>
<div>
<Link to="/product-list" > Product List Page </Link>
</div>
</div>
);
};
There is a link is defined to product-list
page.
2. <Ordered /> component: This component uses the Cart object and populate ordered products.
When the user adds/changes product quantities from the ShoppingProductList component, the same Cart
object will be updated. Hence we can think of this object as a global object available to all Micro Frontends. (In a more advanced setup, Redux store can replace this)
3. <Switch> component: Loads the relevant component based on the URL of the browser. Link
can be used to change browser path.
Here we see a component will be loaded to /product-list
(which will happen when the user clicks on the Link
)
Buckle up, the most important part comes next.
The critical functionality of Micro Frontend architecture is achieved through ComponentLoader
component.
class ComponentLoader extends React.Component {
constructor(props) {
super(props);
this.state = {
Component: null,
}
} ... render() {
const { Component } = this.state;
if (Component) {
return <Component {...this.props} />
}
return <div> Loading </div>;
}
}
Before moving to complicated bits, let’s look at constructor
and render
methods. It’s relatively easy to understand. We are expecting something good to be loaded to Component
state variable and once it does we render it. Until then primitive Loading label is shown (Of course we can add fancy loader here or plug a loader component as a prop).
Now the final piece. ComponentDidMount
implementation,
componentDidMount() {
const { componentName } = this.props; const scriptId = `MicroFrontend-${componentName}`;
if (document.getElementById(scriptId)) {
const Component = getMicroFrontEndComponent(componentName);
this.setState({ Component });
return;
}
const scriptSrc = URIMapper[componentName];
const scriptOnLoad = (e) => {
const Component = getMicroFrontEndComponent(componentName);
this.setState({ Component });
};
scriptLoader(scriptId, scriptSrc, scriptOnLoad);
}
What we are trying to achieve here is to load the manifest file (containing the ShoppingProductList React component) from the server running the Product List app into the Container app by creating a <script>
tag with src and onload values.
Also, note that if the script
is already loaded, it will not load the React component from the server.
const scriptLoader = (id, src, onLoad) => {
const script = document.createElement('script');
script.id = id;
script.src = src;
script.onload = onLoad;
// Allow access cross origin scripts
script.crossorigin = "anonymous";
document.head.appendChild(script);
};
For src
we need the URL of the server. This information is saved in the URIMapper
object. As an example,
const URIMapper = {
'ShoppingProductList': `${PRODUCT_LIST_URL}/manifest`,
'ShoppingCart': `${SHOPPING_CART_URL}/manifest`,
};
Earlier we saw componentName prop was passed to ComponentLoader for product-list
as ShoppingProductList. We have a direct mapping for that in the URIMapper
.
And also this is the exact same name as the React component exported through our Product List Micro Frontend.
Once the script is loaded, we have the module on the global level, thus we can access it by,
const getMicroFrontEndComponent = (moduleName) => {
return window[moduleName].default;
};
As we have used export default ShoppingProductList;
we need to access the default
in the module to get the React component.
Finally, we will load the ShoppingProductList React component toComponentLoaders
state variable Component
by setState
method and then render method will handle the rest.
So we have loaded the Product List app into the Container app. In this manner, we can add more Micro Frontends to the Container app to get a full functioning Shopping application.
I hope this was helpful and please share your thoughts!