Managing an application built with micro-frontends feels a lot like trying to get several different groups of people to collaborate on a single, giant whiteboard. Each team owns their own section of the board, but certain information—like the user’s name, a shopping cart, or a theme preference—needs to be seen and updated by everyone. How do you let one team write “User: Alex” at the top without causing arguments or forcing everyone to redraw their work constantly? That is the central challenge of shared state.
I see this pattern emerge repeatedly. We gain wonderful independence by splitting our frontend into separate, deployable units. Each team can move at its own speed. But the moment a user logs in, that fact becomes a universal truth every piece of the application needs to respect. If the shopping cart icon in the header doesn’t update when the product page adds an item, the user loses trust. Our technical decision suddenly has a direct impact on human experience.
The goal, then, is not to avoid shared state, but to manage it with clear, deliberate rules. We must create a system that is predictable, reliable, and does not tie our independent teams into knots. Let me walk you through the approaches I’ve seen work, from simple to sophisticated.
A straightforward method is to create a shared event bus. Think of it as a public announcement system inside your application. Any part of the app can shout out a change, and any other part that’s listening can react.
Here’s a basic version you might set up in the main host application that loads all the micro-frontends.
// A simple, custom state bus
class AppStateBus {
constructor() {
this.currentState = {};
this.subscriptions = new Map(); // Keeps track of who is listening to what
}
// To update a value
update(key, newValue) {
const previousValue = this.currentState[key];
this.currentState[key] = newValue;
// Announce the change to all listeners for this key
this._notify(key, newValue, previousValue);
}
// To read a value
read(key) {
return this.currentState[key];
}
// To listen for changes
on(key, callback) {
if (!this.subscriptions.has(key)) {
this.subscriptions.set(key, new Set());
}
this.subscriptions.get(key).add(callback);
// Return a function to cancel the subscription
return () => this.subscriptions.get(key).delete(callback);
}
// The notification logic
_notify(key, newValue, oldValue) {
const listeners = this.subscriptions.get(key);
if (listeners) {
listeners.forEach(listener => listener(newValue, oldValue));
}
}
}
// Make it globally available
window.appState = new AppStateBus();
Now, your host app can set the initial user information.
// The host sets the user after login
window.appState.update('user', {
id: 'usr_789',
name: 'Jamie Rivera',
isAuthenticated: true
});
A remote micro-frontend, like a user profile widget, can listen for this. It doesn’t need to know where the data came from, only that it should update when a change is announced.
// Inside a remote user-widget module
function setupUserWidget() {
// Subscribe to 'user' changes
const removeListener = window.appState.on('user', (currentUser) => {
const widgetElement = document.getElementById('user-greeting');
if (widgetElement) {
widgetElement.textContent = currentUser ?
`Hello, ${currentUser.name}` :
'Sign In';
}
});
// Get the initial value immediately
const initialUser = window.appState.read('user');
if (initialUser) {
document.getElementById('user-greeting').textContent = `Hello, ${initialUser.name}`;
}
// Crucial: Clean up when this widget is destroyed
return removeListener;
}
// When the widget loads
const cleanup = setupUserWidget();
// Later, when the widget is removed (e.g., navigating away)
// cleanup();
This pattern is beautifully simple. However, as your app grows, you might want more structure, like enforced state shapes and predictable update logic. This is where dedicated state management libraries, shared as a micro-frontend themselves, come into play.
Imagine a single, small module whose only job is to be the official keeper of state. Other modules import this “state module” at runtime.
// shared-state-module (built and deployed independently)
// This uses a Redux-like pattern for clarity
import { createStore } from 'redux';
const defaultState = {
session: { user: null, token: null },
preferences: { theme: 'light', language: 'en' },
cart: { items: [], total: 0 }
};
function rootReducer(state = defaultState, action) {
switch (action.type) {
case 'SESSION_SET_USER':
return {
...state,
session: { ...state.session, user: action.user }
};
case 'CART_ADD_ITEM':
const newItems = [...state.cart.items, action.item];
const newTotal = newItems.reduce((sum, i) => sum + i.price, 0);
return {
...state,
cart: { items: newItems, total: newTotal }
};
case 'PREFERENCES_SET_THEME':
return {
...state,
preferences: { ...state.preferences, theme: action.theme }
};
default:
return state;
}
}
const storeInstance = createStore(rootReducer);
// Public API for other micro-frontends
export const getAppState = storeInstance.getState;
export const sendAction = storeInstance.dispatch;
export const subscribeToChanges = storeInstance.subscribe;
// A helper to create standard actions
export const actions = {
setUser: (userData) => ({ type: 'SESSION_SET_USER', user: userData }),
addToCart: (product) => ({ type: 'CART_ADD_ITEM', item: product }),
setTheme: (themeName) => ({ type: 'PREFERENCES_SET_THEME', theme: themeName })
};
The host or any other micro-frontend uses this module via dynamic imports.
// Inside a product page micro-frontend
let sharedState;
async function loadSharedDependencies() {
// Dynamically import the shared state module
const stateModule = await import('https://assets.myapp.com/shared-state/v1.0.0/index.js');
sharedState = {
getState: stateModule.getAppState,
dispatch: stateModule.sendAction,
actions: stateModule.actions
};
// Now react to cart changes
stateModule.subscribeToChanges(() => {
const currentCart = stateModule.getAppState().cart;
updateCartIcon(currentCart.items.length);
});
// We can now dispatch actions
document.getElementById('buy-button').addEventListener('click', () => {
sharedState.dispatch(sharedState.actions.addToCart({
id: 'prod_123',
name: 'Wireless Headphones',
price: 199.99
}));
});
}
loadSharedDependencies();
This approach is powerful but introduces a new problem: what happens when the user refreshes the page? The state vanishes. We need persistence. A common solution is to synchronize this shared state with the browser’s storage, while also making it work across multiple open tabs.
Here is a more advanced class that handles this.
class PersistentCrossTabState {
constructor(storageKey = 'app_global_state') {
this.storageKey = storageKey;
this.data = this._load();
this.listeners = new Map();
// Listen for changes from OTHER browser tabs
window.addEventListener('storage', (event) => {
if (event.key === this.storageKey) {
this.data = JSON.parse(event.newValue || '{}');
this._informAllListeners();
}
});
}
_load() {
try {
const raw = localStorage.getItem(this.storageKey);
return raw ? JSON.parse(raw) : {};
} catch (error) {
console.warn('Could not load state from storage:', error);
return {};
}
}
_save() {
try {
localStorage.setItem(this.storageKey, JSON.stringify(this.data));
// Trigger the event manually so other tabs in the same browser react
window.dispatchEvent(new StorageEvent('storage', {
key: this.storageKey,
newValue: JSON.stringify(this.data)
}));
} catch (error) {
console.warn('Could not save state to storage:', error);
}
}
set(key, value) {
const oldValue = this.data[key];
this.data[key] = value;
this._save();
this._informListeners(key, value, oldValue);
}
get(key) {
return this.data[key];
}
watch(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key).add(callback);
// Return an 'unwatch' function
return () => this.listeners.get(key).delete(callback);
}
_informListeners(key, newVal, oldVal) {
const keyListeners = this.listeners.get(key);
if (keyListeners) {
keyListeners.forEach(cb => cb(newVal, oldVal));
}
}
_informAllListeners() {
for (const [key, value] of Object.entries(this.data)) {
this._informListeners(key, value, value);
}
}
}
// Use it in your app
const globalState = new PersistentCrossTabState('my_microfrontend_app');
// Setting a theme preference persists it
globalState.set('ui_theme', 'dark_mode');
// Another tab will receive this via the 'storage' event
// and can update its interface accordingly.
With these patterns established, we must consider performance and stability. You cannot have every single component listening to every state change. It creates noise and slows everything down.
The solution is selective subscription. A component should only listen for the specific piece of data it cares about. In our event bus or persistent state examples, we already did this by subscribing to a key like 'user' or 'cart'. This is efficient. A header cart icon subscribes to cart. A profile page subscribes to user. They ignore other updates.
Memory management is equally critical. Micro-frontends get loaded and unloaded. If you don’t clean up subscriptions, they linger in memory, causing leaks. Notice how our examples always return an unsubscribe function. Calling that function when a component is destroyed is a non-negotiable practice.
// A pattern for safe subscription and cleanup
class ShoppingCartComponent {
constructor() {
this.unsubscribeCallbacks = [];
}
mount() {
// Subscribe to cart changes
const unsubCart = window.appState.on('cart', this.updateCartDisplay.bind(this));
this.unsubscribeCallbacks.push(unsubCart);
// Subscribe to user changes (maybe for displaying a promo)
const unsubUser = window.appState.on('user', this.checkForUserPromo.bind(this));
this.unsubscribeCallbacks.push(unsubUser);
}
unmount() {
// Clean up ALL subscriptions when this component is removed
this.unsubscribeCallbacks.forEach(fn => fn());
this.unsubscribeCallbacks = [];
}
updateCartDisplay(cart) { /* ... */ }
checkForUserPromo(user) { /* ... */ }
}
Things will go wrong. The network request for your shared state module might fail. A new version of a micro-frontend might expect a different shape for the user object. We need to plan for these failures.
For loading errors, wrap your state integration in a try-catch and set sensible defaults.
let appState = {
get: () => null,
set: () => console.error('State system not loaded'),
on: () => () => {} // Returns a no-op unsubscribe function
};
async function initializeApp() {
try {
const module = await import('https://assets.myapp.com/shared-state-module.js');
// Assume the module provides a compatible API
appState = module;
} catch (loadError) {
console.error('Failed to load shared state. Using fallback.', loadError);
// App continues with limited functionality
document.body.classList.add('state-offline');
}
// Now start your app components, which use the `appState` object
startHeaderWidget(appState);
startProductPage(appState);
}
For version conflicts, one strategy is to add a version number to your state’s structure or to the module’s URL. If a module expects v2 of the state but only v1 is available, it can operate in a compatibility mode or show a helpful message.
Finally, we must test this. Testing isn’t just about units; it’s about how these independent pieces talk to each other.
You need tests that simulate the integration.
// An example integration test using a mock state bus
import { JSDOM } from 'jsdom';
import { MyMicrofrontend } from '../src/my-component.js';
describe('ShoppingCart Integration', () => {
let mockStateBus;
let dom;
beforeEach(() => {
// Set up a fake browser environment
dom = new JSDOM(`<!DOCTYPE html><html><body></body></html>`);
global.window = dom.window;
global.document = window.document;
// Create a mock state bus
mockStateBus = {
state: {},
on: jest.fn((key, cb) => {
// Store the callback to simulate events later
mockStateBus.listeners = mockStateBus.listeners || {};
mockStateBus.listeners[key] = cb;
return jest.fn(); // Mock unsubscribe function
}),
get: jest.fn((key) => mockStateBus.state[key]),
set: jest.fn((key, val) => {
mockStateBus.state[key] = val;
if (mockStateBus.listeners[key]) {
mockStateBus.listeners[key](val); // Trigger the listener
}
})
};
// Make it globally available as our real app would
window.appState = mockStateBus;
});
test('MyMicrofrontend subscribes to cart changes on mount', () => {
const component = new MyMicrofrontend();
component.mount();
expect(mockStateBus.on).toHaveBeenCalledWith('cart', expect.any(Function));
});
test('MyMicrofrontend updates UI when cart state changes', () => {
const component = new MyMicrofrontend();
component.mount();
// Simulate a state change from another micro-frontend
mockStateBus.set('cart', [{ id: '1', name: 'Test Item' }]);
// Check that the component's internal UI update method was called
expect(component.updateCartDisplay).toHaveBeenCalledWith([{ id: '1', name: 'Test Item' }]);
});
});
In the end, managing shared state in a micro-frontend architecture is about establishing clear protocols for communication. It is the difference between a crowded room where everyone is shouting and an organized meeting with a designated speaker. The patterns I’ve described—the event bus, the shared library module, and the persistent cross-tab state—are those protocols.
They allow the product team to own the shopping cart, the platform team to own user authentication, and the UX team to own theme preferences, all while presenting a single, coherent application to the person using it. The complexity is moved from a tangled web of dependencies into a well-defined, central service. You get independence where it matters and consistency where the user needs it. That balance is what makes this architecture not just possible, but powerfully effective at scale.