Micro-Frontends Architecture: Implementing and Orchestrating with React

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

  1. 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.

  1. 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

  1. Start the server:

     cd server
     node index.js
    
  2. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.