Micro-Frontends Architecture: Implementing and Orchestrating with React
Photo by Markus Spiske on Unsplash
Micro-frontends is an architectural style where a frontend application is decomposed into smaller, more manageable pieces that can be developed, tested, and deployed independently. This approach extends the concepts of microservices to the frontend world, allowing teams to work more autonomously and deploy features faster.
Key benefits of micro-frontends include:
Incremental upgrades
Simple, decoupled codebases
Independent deployment
Autonomous teams
- Core Concepts of Micro-Frontends
Before diving into implementation, let's understand the core concepts:
a) Composition: How different micro-frontends are combined into a cohesive application. b) Routing: How navigation between micro-frontends is handled. c) Communication: How micro-frontends share data and interact with each other. d) Shared dependencies: How common libraries and styles are managed.
- Implementing Micro-Frontends with React
Let's walk through a step-by-step process of creating a micro-frontend architecture using React.
Step 1: Setting up the Container Application
The container application will serve as the shell that hosts our micro-frontends. Let's create it using Create React App:
npx create-react-app container
cd container
Now, let's modify the App.js
to prepare it for hosting micro-frontends:
import React from 'react';
import { BrowserRouter as Router, Route, Switch, Link } from 'react-router-dom';
const App = () => {
return (
<Router>
<div>
<nav>
<ul>
<li><Link to="/">Home</Link></li>
<li><Link to="/app1">App 1</Link></li>
<li><Link to="/app2">App 2</Link></li>
</ul>
</nav>
<Switch>
<Route exact path="/" component={() => <h1>Home</h1>} />
<Route path="/app1" component={() => <div id="app1"></div>} />
<Route path="/app2" component={() => <div id="app2"></div>} />
</Switch>
</div>
</Router>
);
};
export default App;
Step 2: Creating Micro-Frontend Applications
Now, let's create two separate micro-frontend applications. We'll use Create React App for these as well:
npx create-react-app app1
npx create-react-app app2
For each of these applications, we'll modify the package.json
to build them as libraries:
{
"scripts": {
"start": "rescripts start",
"build": "rescripts build",
"test": "rescripts test",
"eject": "react-scripts eject"
},
"devDependencies": {
"@rescripts/cli": "^0.0.16",
"@rescripts/rescript-env": "^0.0.12"
}
}
And create a .rescriptsrc.js
file in each app's root:
const path = require('path');
module.exports = [
config => {
config.output.libraryTarget = 'umd';
config.output.library = '[name]';
config.output.filename = 'static/js/[name].js';
config.optimization.runtimeChunk = false;
config.optimization.splitChunks = {
cacheGroups: {
default: false
}
};
return config;
}
];
Step 3: Implementing the Micro-Frontends
Let's implement a simple counter in each micro-frontend.
In app1/src/App.js
:
import React, { useState } from 'react';
const App = () => {
const [count, setCount] = useState(0);
return (
<div>
<h1>App 1</h1>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
export default App;
Similarly for app2/src/App.js
, but with a different title.
Step 4: Building and Serving Micro-Frontends
Build each micro-frontend:
cd app1
npm run build
cd ../app2
npm run build
Now, we need to serve these built files. For simplicity, we'll use a basic Express server. Create a new directory called server
and initialize a new Node.js project:
mkdir server
cd server
npm init -y
npm install express cors
Create an index.js
file in the server
directory:
const express = require('express');
const cors = require('cors');
const path = require('path');
const app = express();
app.use(cors());
app.use('/app1', express.static(path.join(__dirname, '../app1/build')));
app.use('/app2', express.static(path.join(__dirname, '../app2/build')));
app.listen(5000, () => console.log('Server running on port 5000'));
Step 5: Loading Micro-Frontends in the Container
Now, we need to dynamically load our micro-frontends in the container application. We'll use a custom hook for this. In the container app, create a new file src/useMicroFrontend.js
:
import { useEffect } from 'react';
const useMicroFrontend = (name, url) => {
useEffect(() => {
const script = document.createElement('script');
script.src = url;
script.onload = () => {
window[name].default(document.getElementById(name));
};
document.head.appendChild(script);
return () => {
document.head.removeChild(script);
};
}, [name, url]);
};
export default useMicroFrontend;
Now, update the App.js
in the container to use this hook:
import React from 'react';
import { BrowserRouter as Router, Route, Switch, Link } from 'react-router-dom';
import useMicroFrontend from './useMicroFrontend';
const App1 = () => {
useMicroFrontend('app1', 'http://localhost:5000/app1/static/js/main.js');
return <div id="app1"></div>;
};
const App2 = () => {
useMicroFrontend('app2', 'http://localhost:5000/app2/static/js/main.js');
return <div id="app2"></div>;
};
const App = () => {
return (
<Router>
<div>
<nav>
<ul>
<li><Link to="/">Home</Link></li>
<li><Link to="/app1">App 1</Link></li>
<li><Link to="/app2">App 2</Link></li>
</ul>
</nav>
<Switch>
<Route exact path="/" component={() => <h1>Home</h1>} />
<Route path="/app1" component={App1} />
<Route path="/app2" component={App2} />
</Switch>
</div>
</Router>
);
};
export default App;
Step 6: Running the Application
Start the server:
cd server node index.js
Start the container application:
cd container npm start
Now, when you navigate to http://localhost:3000
, you should see the container application with links to App 1 and App 2. Clicking on these links should load the respective micro-frontends.
- Advanced Concepts
a) Sharing State Between Micro-Frontends
For micro-frontends to communicate, we can use a combination of custom events and localStorage. Here's an example:
In app1/src/App.js
:
import React, { useState, useEffect } from 'react';
const App = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const storedCount = localStorage.getItem('count');
if (storedCount) setCount(parseInt(storedCount, 10));
}, []);
const incrementCount = () => {
const newCount = count + 1;
setCount(newCount);
localStorage.setItem('count', newCount);
window.dispatchEvent(new CustomEvent('countUpdated', { detail: newCount }));
};
return (
<div>
<h1>App 1</h1>
<p>Count: {count}</p>
<button onClick={incrementCount}>Increment</button>
</div>
);
};
export default App;
In app2/src/App.js
:
import React, { useState, useEffect } from 'react';
const App = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const storedCount = localStorage.getItem('count');
if (storedCount) setCount(parseInt(storedCount, 10));
const handleCountUpdate = (event) => setCount(event.detail);
window.addEventListener('countUpdated', handleCountUpdate);
return () => window.removeEventListener('countUpdated', handleCountUpdate);
}, []);
return (
<div>
<h1>App 2</h1>
<p>Shared Count: {count}</p>
</div>
);
};
export default App;
b) Styling
To manage styles across micro-frontends, consider using CSS-in-JS solutions like styled-components or CSS Modules. These help in scoping styles to specific components and avoid global conflicts.
c) Shared Dependencies
To avoid duplicating common libraries across micro-frontends, you can use Webpack's externals configuration to treat certain dependencies as external. Then, load these shared dependencies in the container application.
- Testing Micro-Frontends
Each micro-frontend should have its own suite of unit and integration tests. For end-to-end testing of the entire application, consider using tools like Cypress or Selenium.
- Deployment
Each micro-frontend can be deployed independently to its own server or CDN. The container application would then load these micro-frontends from their respective URLs.
- Challenges and Considerations
Performance: Loading multiple JavaScript bundles can impact initial load time. Consider using techniques like lazy loading and code splitting.
Consistency: Ensuring a consistent user experience across independently developed micro-frontends can be challenging.
Versioning: Managing different versions of micro-frontends and their dependencies requires careful coordination.
Micro-frontends offer a powerful way to scale frontend development, allowing teams to work more independently and deploy features faster. While they introduce some complexity, the benefits in terms of scalability and maintainability can be significant for large applications.