JavaScript and React course Β· Module 10: React Ecosystem and Future

Micro-frontends with React - Scalable Architecture

7 min read
In this lesson4

The fleet is growing: five teams work on one application, and every deployment needs everyone's approval, because the code is a single monolith. A fix in the cart waits for the product catalog tests, and a version conflict in one library blocks the whole crew. Micro-frontends are an architectural pattern that brings the idea of microservices to the frontend: independent teams develop, deploy and scale separate parts of the application.

Introduction to Micro-frontends

What Are Micro-frontends?

Micro-frontends are a technique for splitting a monolithic frontend application into smaller, independent applications (MFEs), each with its own repository and deployment process. The user sees a single page, because a shell application puts them together. You read the architecture from the outside in: the shell, inside it the router, the router loads the remote MFEs, and each MFE renders its own internal components. It is like a space station assembled from modules built by different agencies, joined by a shared dock.

Module Federation (Webpack 5)

The most popular way to connect MFEs is Module Federation, built into webpack 5 as require('webpack').container.ModuleFederationPlugin. It lets an application load, at runtime, modules exposed by other applications and share dependencies, e.g. React. This is what the shell configuration looks like:

1// webpack.config.js - Shell Application
2const { ModuleFederationPlugin } = require('webpack').container;
3
4module.exports = {
5  mode: 'development',
6  devServer: {
7    port: 3000,
8  },
9  plugins: [
10    new ModuleFederationPlugin({
11      name: 'shell',
12      remotes: {
13        products: 'products@http://localhost:3001/remoteEntry.js',
14        cart: 'cart@http://localhost:3002/remoteEntry.js',
15        user: 'user@http://localhost:3003/remoteEntry.js',
16      },
17      shared: {
18        react: { singleton: true, eager: true },
19        'react-dom': { singleton: true, eager: true },
20        '@shared/ui-kit': { singleton: true },
21        '@shared/utils': { singleton: true },
22      },
23    }),
24  ],
25};

remotes maps names to the addresses of the remoteEntry.js files that each MFE publishes. shared with singleton: true makes sure only one copy of React runs in the browser, because libraries with global state handle duplicates badly, and eager: true includes them in the initial bundle. For extras, such as a manifest and runtime plugins, reach for Module Federation 2.0 from the @module-federation/enhanced package.

Implementing the Shell Application

The shell loads MFEs lazily with lazy, just like regular components. The products/ProductsApp path points to a module from the remote named products:

1// src/App.jsx - Shell Application
2import React, { Suspense, lazy } from 'react';
3import { BrowserRouter, Routes, Route } from 'react-router-dom';
4import ErrorBoundary from './components/ErrorBoundary';
5import Layout from './components/Layout';
6
7// Lazy loading Micro-frontends
8const ProductsApp = lazy(() => import('products/ProductsApp'));
9const CartApp = lazy(() => import('cart/CartApp'));
10const UserApp = lazy(() => import('user/UserApp'));
11
12// Local components
13const HomePage = lazy(() => import('./pages/HomePage'));
14
15function App() {
16  return (
17    <ErrorBoundary>
18      <BrowserRouter>
19        <Layout>
20          <Suspense fallback={<div>Loading...</div>}>
21            <Routes>
22              <Route path="/" element={<HomePage />} />
23
24              {/* Products Micro-frontend */}
25              <Route
26                path="/products/*"
27                element={
28                  <ErrorBoundary fallback={<div>Products unavailable</div>}>
29                    <ProductsApp />
30                  </ErrorBoundary>
31                }
32              />
33
34              {/* Cart Micro-frontend */}
35              <Route
36                path="/cart/*"
37                element={
38                  <ErrorBoundary fallback={<div>Cart unavailable</div>}>
39                    <CartApp />
40                  </ErrorBoundary>
41                }
42              />
43
44              {/* User Micro-frontend */}
45              <Route
46                path="/user/*"
47                element={
48                  <ErrorBoundary fallback={<div>User profile unavailable</div>}>
49                    <UserApp />
50                  </ErrorBoundary>
51                }
52              />
53            </Routes>
54          </Suspense>
55        </Layout>
56      </BrowserRouter>
57    </ErrorBoundary>
58  );
59}
60
61export default App;

Each MFE has its own ErrorBoundary, so a cart failure will not take down the catalog, and Suspense shows a loading state before the remote code arrives over the network. Paths ending in /* hand the rest of the address to the router inside the MFE.

Communication Between Micro-frontends

Event-driven Architecture

Micro-frontends should not import each other, because then a monolith forms again. Most often they talk through events: an in-memory Event Bus and native CustomEvent on the window object:

1// shared/eventBus.js
2class EventBus {
3  constructor() {
4    this.events = {};
5  }
6
7  subscribe(eventName, callback) {
8    if (!this.events[eventName]) {
9      this.events[eventName] = [];
10    }
11    this.events[eventName].push(callback);
12
13    // Return unsubscribe function
14    return () => {
15      this.events[eventName] = this.events[eventName].filter(
16        cb => cb !== callback
17      );
18    };
19  }
20
21  emit(eventName, data) {
22    if (this.events[eventName]) {
23      this.events[eventName].forEach(callback => callback(data));
24    }
25
26    // Also emit as CustomEvent for cross-MFE communication
27    window.dispatchEvent(new CustomEvent(`mfe:${eventName}`, {
28      detail: data
29    }));
30  }
31}
32
33export const eventBus = new EventBus();

subscribe returns an unsubscribe function, so a component will detach itself in the effect's cleanup. emit also dispatches a CustomEvent with the mfe: prefix, which every MFE will hear, even one written in a different framework.

Shared State Management

Some data, like the logged-in user or the cart, has to be shared. A store from zustand/vanilla works without React, so it can live in a shared module:

1// shared/stateManager.js
2import { createStore } from 'zustand/vanilla';
3
4// Global store accessible across MFEs
5const globalStore = createStore((set, get) => ({
6  // User state
7  user: null,
8  isAuthenticated: false,
9
10  // Cart state
11  cartItems: [],
12  cartTotal: 0,
13
14  // Actions
15  setUser: (user) => set({ user, isAuthenticated: !!user }),
16
17  addToCart: (product) => set(state => {
18    const existingItem = state.cartItems.find(item => item.id === product.id);
19    let newItems;
20
21    if (existingItem) {
22      newItems = state.cartItems.map(item =>
23        item.id === product.id
24          ? { ...item, quantity: item.quantity + 1 }
25          : item
26      );
27    } else {
28      newItems = [...state.cartItems, { ...product, quantity: 1 }];
29    }
30
31    const newTotal = newItems.reduce((sum, item) =>
32      sum + (item.price * item.quantity), 0
33    );
34
35    return { cartItems: newItems, cartTotal: newTotal };
36  }),
37
38  removeFromCart: (productId) => set(state => {
39    const newItems = state.cartItems.filter(item => item.id !== productId);
40    const newTotal = newItems.reduce((sum, item) =>
41      sum + (item.price * item.quantity), 0
42    );
43
44    return { cartItems: newItems, cartTotal: newTotal };
45  }),
46}));

addToCart increases quantity when the product is already in the cart, and cartTotal is recalculated on every change. A shared store is still a dependency between teams, though, so I advise you to keep only truly global data in it, and send everything else through events.

Testing Micro-frontends

An integration test checks whether the shell loads the MFE correctly, and the remote module is replaced by a mock from jest.mock:

1// __tests__/integration/mfe-integration.test.jsx
2import { render, screen, waitFor } from '@testing-library/react';
3import userEvent from '@testing-library/user-event';
4import App from '../App';
5
6// Mock of the remote MFE: the module is not on disk, hence virtual: true
7jest.mock('products/ProductsApp', () => {
8  return function MockProductsApp() {
9    return <div data-testid="products-mfe">Products MFE Loaded</div>;
10  };
11}, { virtual: true });
12
13describe('MFE Integration', () => {
14  test('loads products MFE when navigating to /products', async () => {
15    const user = userEvent.setup();
16
17    // App renders BrowserRouter itself, so we do not wrap it in a second router
18    render(<App />);
19
20    // Navigate to products
21    await user.click(screen.getByRole('link', { name: /products/i }));
22
23    // Verify MFE is loaded
24    await waitFor(() => {
25      expect(screen.getByTestId('products-mfe')).toBeInTheDocument();
26    });
27  });
28});

The test clicks a link, and waitFor waits until the lazily loaded mock appears in the DOM. The remote module is not on disk, which is why jest.mock gets the { virtual: true } option (instead, you can add an entry in moduleNameMapper). App renders BrowserRouter itself, so the test renders it without a second router, because React Router would report a nesting error.

When Is It Worth It?

Micro-frontends offer powerful ways to scale React applications in large organizations, but they require a well-thought-out architecture and managing complexity: separate pipelines, event contracts and keeping an eye on the versions of shared dependencies. For a single team it is usually overkill, because a well-divided monolith will be simpler. In the exercise you will build the orchestration of several MFEs with shared dependencies and event-based communication.

Remember: a micro-frontend is a station module from another agency - it docks to the shared shell, but launches on its own schedule. In the example below, switch a section in the navigation or turn off a system: the communicator receives an event from the Event Bus, and the shell hears a CustomEvent on the window object.

Code for this lesson: App.jsx
1import { useCallback, useEffect, useState } from 'react';
2
3// === Micro-frontends - a simulation in a single file ===
4// Each MF module pretends to be a separate application with its own state. The modules
5// do not import each other, they talk through the Event Bus, as in the lesson.
6
7class EventBus {
8  constructor() {
9    this.events = {};
10  }
11
12  subscribe(eventName, callback) {
13    if (!this.events[eventName]) this.events[eventName] = [];
14    this.events[eventName].push(callback);
15
16    // Unsubscribe function for the effect cleanup
17    return () => {
18      this.events[eventName] = this.events[eventName].filter(cb => cb !== callback);
19    };
20  }
21
22  emit(eventName, data) {
23    (this.events[eventName] || []).forEach(callback => callback(data));
24
25    // A CustomEvent with the mfe: prefix is heard even by an MF written in another framework
26    window.dispatchEvent(new CustomEvent('mfe:' + eventName, { detail: data }));
27  }
28}
29
30const eventBus = new EventBus();
31
32// Hook for listening to events from other modules
33function useEventBus(eventName, handler) {
34  useEffect(() => eventBus.subscribe(eventName, handler), [eventName, handler]);
35}
36
37// MF-1: Space station navigation
38function NavigationModule() {
39  const [activeSection, setActiveSection] = useState('panel');
40  const sections = [
41    { id: 'panel', label: 'Main panel' },
42    { id: 'crew', label: 'Crew' },
43    { id: 'systems', label: 'Systems' },
44    { id: 'cargo', label: 'Cargo hold' },
45  ];
46
47  const navigate = section => {
48    setActiveSection(section.id);
49    eventBus.emit('navigation:change', { label: section.label });
50  };
51
52  return (
53    <div style={{ ...styles.module, border: '2px solid #00d4ff' }}>
54      <div style={styles.moduleHeader}>
55        <h3 style={{ ...styles.moduleTitle, color: '#00d4ff' }}>MF-1: Navigation</h3>
56        <span style={styles.port}>port 3001</span>
57      </div>
58      <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
59        {sections.map(section => (
60          <button
61            key={section.id}
62            onClick={() => navigate(section)}
63            style={{ ...styles.navButton, ...(activeSection === section.id ? styles.navActive : null) }}
64          >
65            {section.label}
66          </button>
67        ))}
68      </div>
69    </div>
70  );
71}
72
73// MF-2: System status panel
74function SystemStatusModule() {
75  const [systems, setSystems] = useState([
76    { name: 'Warp drive', online: true, health: 95 },
77    { name: 'Shields', online: true, health: 88 },
78    { name: 'Communications', online: true, health: 100 },
79    { name: 'Life support', online: true, health: 92 },
80  ]);
81
82  const toggleSystem = index => {
83    const system = systems[index];
84    const online = !system.online;
85    // New state first, then the event - the setState updater stays pure
86    setSystems(prev => prev.map((s, i) => (i === index ? { ...s, online } : s)));
87    eventBus.emit('status:update', { system: system.name, online });
88  };
89
90  return (
91    <div style={{ ...styles.module, border: '2px solid #00ff88' }}>
92      <div style={styles.moduleHeader}>
93        <h3 style={{ ...styles.moduleTitle, color: '#00ff88' }}>MF-2: System status</h3>
94        <span style={styles.port}>port 3002</span>
95      </div>
96      {systems.map((system, i) => (
97        <button key={system.name} onClick={() => toggleSystem(i)} style={styles.systemRow}>
98          <span style={{ ...styles.dot, background: system.online ? '#00ff88' : '#ff6b6b' }} />
99          <span style={{ flex: 1, textAlign: 'left' }}>{system.name}</span>
100          <span style={{ color: '#778da9' }}>{system.online ? system.health + '%' : 'offline'}</span>
101        </button>
102      ))}
103    </div>
104  );
105}
106
107// MF-3: Crew communicator - listens to events from MF-1 and MF-2
108function CrewChatModule() {
109  const [messages, setMessages] = useState([
110    { from: 'Commander Nova', text: 'Systems operational, ready to jump.', type: 'crew' },
111    { from: 'Astro', text: 'Warp drive charged.', type: 'crew' },
112  ]);
113  const [input, setInput] = useState('');
114
115  // Stable functions (useCallback), so we do not resubscribe to the bus on every render
116  const onStatusUpdate = useCallback(data => {
117    setMessages(prev => [...prev, { from: 'System', text: data.system + (data.online ? ' online' : ' offline'), type: 'system' }]);
118  }, []);
119
120  const onNavigationChange = useCallback(data => {
121    setMessages(prev => [...prev, { from: 'Navigation', text: 'Moved to section: ' + data.label, type: 'system' }]);
122  }, []);
123
124  useEventBus('status:update', onStatusUpdate);
125  useEventBus('navigation:change', onNavigationChange);
126
127  const send = () => {
128    if (!input.trim()) return;
129    setMessages(prev => [...prev, { from: 'You', text: input, type: 'crew' }]);
130    setInput('');
131  };
132
133  return (
134    <div style={{ ...styles.module, border: '2px solid #ffaa00' }}>
135      <div style={styles.moduleHeader}>
136        <h3 style={{ ...styles.moduleTitle, color: '#ffaa00' }}>MF-3: Communicator</h3>
137        <span style={styles.port}>port 3003</span>
138      </div>
139      <div style={styles.chat}>
140        {messages.map((msg, i) => (
141          <div key={i} style={{ marginBottom: '4px' }}>
142            <strong style={{ color: msg.type === 'system' ? '#ff6b6b' : '#00d4ff' }}>{msg.from}: </strong>
143            <span>{msg.text}</span>
144          </div>
145        ))}
146      </div>
147      <div style={{ display: 'flex', gap: '6px' }}>
148        <input
149          value={input}
150          onChange={e => setInput(e.target.value)}
151          onKeyDown={e => e.key === 'Enter' && send()}
152          placeholder="Message..."
153          style={styles.input}
154        />
155        <button onClick={send} style={styles.sendButton}>Send</button>
156      </div>
157    </div>
158  );
159}
160
161// Shell Application - puts the modules together and listens to CustomEvent on window
162export default function App() {
163  const [currentSection, setCurrentSection] = useState('Main panel');
164
165  useEffect(() => {
166    const onNavigation = event => setCurrentSection(event.detail.label);
167    window.addEventListener('mfe:navigation:change', onNavigation);
168    return () => window.removeEventListener('mfe:navigation:change', onNavigation);
169  }, []);
170
171  return (
172    <div style={styles.page}>
173      <div style={{ maxWidth: '620px', margin: '0 auto' }}>
174        <h1 style={styles.title}>Shell Application - Space Station</h1>
175        <p style={styles.subtitle}>
176          The shell (port 3000) loads three independent modules. Active section according to the event from MF-1: <strong style={{ color: '#00d4ff' }}>{currentSection}</strong>
177        </p>
178
179        <div style={{ display: 'grid', gap: '12px' }}>
180          <NavigationModule />
181          <SystemStatusModule />
182          <CrewChatModule />
183        </div>
184
185        <div style={styles.patterns}>
186          <h4 style={{ margin: '0 0 8px', color: '#778da9' }}>Key micro-frontend patterns:</h4>
187          <ul style={{ margin: 0, paddingLeft: '20px', lineHeight: 1.8 }}>
188            <li><strong style={{ color: '#00d4ff' }}>Module Federation</strong> - loading modules of other applications at runtime</li>
189            <li><strong style={{ color: '#00ff88' }}>Event Bus and CustomEvent</strong> - loosely coupled communication</li>
190            <li><strong style={{ color: '#ffaa00' }}>Independent deployments</strong> - each team deploys its module separately</li>
191            <li><strong style={{ color: '#ff6b6b' }}>Own state</strong> - a shared store only for truly global data</li>
192          </ul>
193        </div>
194      </div>
195    </div>
196  );
197}
198
199const styles = {
200  page: { background: '#0d1b2a', minHeight: '100vh', padding: '20px', color: '#e0e1dd', fontFamily: 'system-ui, sans-serif', fontSize: '13px' },
201  title: { color: '#00d4ff', textAlign: 'center', fontSize: '19px', margin: '0 0 6px' },
202  subtitle: { color: '#778da9', textAlign: 'center', margin: '0 0 16px' },
203  module: { background: '#1b2838', padding: '14px', borderRadius: '12px' },
204  moduleHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' },
205  moduleTitle: { margin: 0, fontSize: '14px' },
206  port: { fontSize: '10px', color: '#778da9', border: '1px solid #415a77', padding: '2px 8px', borderRadius: '4px' },
207  navButton: { padding: '6px 12px', borderRadius: '6px', border: '1px solid #415a77', background: 'transparent', color: '#778da9', cursor: 'pointer' },
208  navActive: { border: '1px solid #00d4ff', background: 'rgba(0,212,255,0.15)', color: '#00d4ff' },
209  systemRow: { display: 'flex', alignItems: 'center', gap: '10px', width: '100%', background: '#0d1b2a', color: '#e0e1dd', border: 'none', padding: '8px 12px', borderRadius: '8px', cursor: 'pointer', marginBottom: '6px', fontSize: '13px' },
210  dot: { width: '8px', height: '8px', borderRadius: '50%', flexShrink: 0 },
211  chat: { background: '#0d1b2a', padding: '10px', borderRadius: '8px', maxHeight: '140px', overflowY: 'auto', marginBottom: '8px' },
212  input: { flex: 1, padding: '6px 10px', borderRadius: '6px', border: '1px solid #415a77', background: '#0d1b2a', color: '#e0e1dd' },
213  sendButton: { padding: '6px 12px', borderRadius: '6px', border: 'none', background: '#ffaa00', color: '#0d1b2a', fontWeight: 'bold', cursor: 'pointer' },
214  patterns: { marginTop: '16px', background: '#1b2838', padding: '12px', borderRadius: '8px', border: '1px solid #415a77' },
215};

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is Module Federation used for in the context of micro-frontends?

  2. 2. Which communication pattern is most commonly used between micro-frontends?

Hands-on tasks in the game

  • Vertical ordering

    Order the elements of micro-frontends architecture from outermost to innermost:

  • Code editor

    Connect two independent micro-frontends with an event bus: the parts catalogue (team Astro MFE) sends the cargo:add event, and the cargo hold (team Commander Nova MFE) receives it. ___BLANK1___: the function returned by on() removes only this one subscription (use the off method with the same event and handler). ___BLANK2___: emit calls every stored function with the event data (payload). ___BLANK3___: the effect in CargoHold returns a function that removes the subscription, so that no orphaned listener stays after hiding the cargo hold (and the double effect in StrictMode does not add parts twice).

Useful articles