JavaScript and React course Β· Module 10: React Ecosystem and Future
Micro-frontends with React - Scalable Architecture
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. What is Module Federation used for in the context of micro-frontends?
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).