PatternsMicrofrontends
Shared State Across Microfrontends
Patterns for sharing state between microfrontends without tight coupling
One of the biggest challenges in microfrontend architecture is managing shared state. This guide covers proven patterns.
π― The Challenge
Problem:
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β Header MFE β β Product MFE β β Cart MFE β
β (needs cart β β (adds items)β β (shows itemsβ
β count) β β β β β
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β β β
ββββββββββββββββββ΄βββββββββββββββββ
How to share cart state?π Pattern 1: Event Bus (Recommended)
Loose coupling through events.
Implementation
// shared/event-bus.ts
type EventCallback = (data: any) => void;
class EventBus {
private events = new Map<string, EventCallback[]>();
subscribe(event: string, callback: EventCallback): () => void {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event)!.push(callback);
// Return unsubscribe function
return () => {
const callbacks = this.events.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
};
}
publish(event: string, data?: any): void {
const callbacks = this.events.get(event);
if (callbacks) {
callbacks.forEach(callback => callback(data));
}
}
clear(): void {
this.events.clear();
}
}
export const eventBus = new EventBus();Usage in MFEs
// Product MFE
import { eventBus } from '@company/shared';
function AddToCartButton({ product }) {
const handleClick = () => {
// Add to cart logic
const cartItem = { id: product.id, name: product.name, price: product.price };
// Publish event
eventBus.publish('cart:item-added', cartItem);
};
return <button onClick={handleClick}>Add to Cart</button>;
}
// Header MFE
import { eventBus } from '@company/shared';
function CartIcon() {
const [count, setCount] = useState(0);
useEffect(() => {
// Subscribe to cart events
const unsubscribe = eventBus.subscribe('cart:item-added', () => {
setCount(prev => prev + 1);
});
return unsubscribe;
}, []);
return (
<div className="cart-icon">
π {count}
</div>
);
}
// Cart MFE
import { eventBus } from '@company/shared';
function CartList() {
const [items, setItems] = useState([]);
useEffect(() => {
const unsubscribe = eventBus.subscribe('cart:item-added', (item) => {
setItems(prev => [...prev, item]);
});
return unsubscribe;
}, []);
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}Pros:
- β Loose coupling
- β Easy to add new subscribers
- β No direct dependencies
Cons:
- β οΈ Hard to debug (no direct calls)
- β οΈ Can lead to event spaghetti
π¦ Pattern 2: Shared State Library
Use a lightweight state management library.
Option A: Zustand (Recommended)
// shared/store.ts
import { create } from 'zustand';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface SharedState {
// Cart state
cart: CartItem[];
addToCart: (item: CartItem) => void;
removeFromCart: (id: string) => void;
clearCart: () => void;
// User state
user: { id: string; name: string } | null;
setUser: (user: any) => void;
// Theme
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
}
export const useSharedStore = create<SharedState>((set) => ({
// Cart
cart: [],
addToCart: (item) => set((state) => ({
cart: [...state.cart, item]
})),
removeFromCart: (id) => set((state) => ({
cart: state.cart.filter(item => item.id !== id)
})),
clearCart: () => set({ cart: [] }),
// User
user: null,
setUser: (user) => set({ user }),
// Theme
theme: 'light',
setTheme: (theme) => set({ theme }),
}));Usage in MFEs
// Product MFE
import { useSharedStore } from '@company/shared';
function AddToCartButton({ product }) {
const addToCart = useSharedStore(state => state.addToCart);
const handleClick = () => {
addToCart({
id: product.id,
name: product.name,
price: product.price,
quantity: 1
});
};
return <button onClick={handleClick}>Add to Cart</button>;
}
// Header MFE
import { useSharedStore } from '@company/shared';
function CartIcon() {
const cartCount = useSharedStore(state => state.cart.length);
return <div className="cart-icon">π {cartCount}</div>;
}
// Cart MFE
import { useSharedStore } from '@company/shared';
function CartList() {
const cart = useSharedStore(state => state.cart);
const removeFromCart = useSharedStore(state => state.removeFromCart);
return (
<ul>
{cart.map(item => (
<li key={item.id}>
{item.name} - ${item.price}
<button onClick={() => removeFromCart(item.id)}>Remove</button>
</li>
))}
</ul>
);
}Pros:
- β Type-safe
- β Easy to debug
- β Selective subscriptions (performance)
- β DevTools support
Cons:
- β οΈ Tighter coupling
- β οΈ All MFEs need same store version
Option B: Jotai (Atomic)
// shared/atoms.ts
import { atom } from 'jotai';
// Cart atoms
export const cartAtom = atom<CartItem[]>([]);
export const cartCountAtom = atom(get => get(cartAtom).length);
// User atom
export const userAtom = atom<User | null>(null);
// Theme atom
export const themeAtom = atom<'light' | 'dark'>('light');// Usage
import { useAtom } from 'jotai';
import { cartAtom } from '@company/shared/atoms';
function AddToCartButton({ product }) {
const [cart, setCart] = useAtom(cartAtom);
const handleClick = () => {
setCart([...cart, product]);
};
return <button onClick={handleClick}>Add to Cart</button>;
}π Pattern 3: Custom Events (Browser Native)
Use browser's CustomEvent API.
// shared/custom-events.ts
export const CustomEvents = {
CART_UPDATED: 'mfe:cart:updated',
USER_LOGGED_IN: 'mfe:user:logged-in',
THEME_CHANGED: 'mfe:theme:changed',
} as const;
export function dispatchCustomEvent(eventName: string, detail?: any) {
window.dispatchEvent(new CustomEvent(eventName, { detail }));
}
export function subscribeToEvent(
eventName: string,
handler: (event: CustomEvent) => void
): () => void {
window.addEventListener(eventName, handler as EventListener);
return () => {
window.removeEventListener(eventName, handler as EventListener);
};
}// Product MFE
import { dispatchCustomEvent, CustomEvents } from '@company/shared';
function AddToCartButton({ product }) {
const handleClick = () => {
dispatchCustomEvent(CustomEvents.CART_UPDATED, {
action: 'add',
item: product
});
};
return <button onClick={handleClick}>Add to Cart</button>;
}
// Header MFE
import { subscribeToEvent, CustomEvents } from '@company/shared';
function CartIcon() {
const [count, setCount] = useState(0);
useEffect(() => {
const unsubscribe = subscribeToEvent(
CustomEvents.CART_UPDATED,
(event: CustomEvent) => {
if (event.detail.action === 'add') {
setCount(prev => prev + 1);
}
}
);
return unsubscribe;
}, []);
return <div className="cart-icon">π {count}</div>;
}Pros:
- β No external dependencies
- β Works with any framework
- β Browser native
Cons:
- β οΈ No type safety (need manual typing)
- β οΈ Global event namespace pollution
- β οΈ No debugging tools
π Pattern 4: BroadcastChannel API
Share state across tabs/windows.
// shared/broadcast.ts
class SharedStateBroadcast {
private channel: BroadcastChannel;
constructor(channelName: string = 'mfe-shared-state') {
this.channel = new BroadcastChannel(channelName);
}
subscribe(callback: (message: any) => void): () => void {
const handler = (event: MessageEvent) => {
callback(event.data);
};
this.channel.addEventListener('message', handler);
return () => {
this.channel.removeEventListener('message', handler);
};
}
publish(type: string, data: any): void {
this.channel.postMessage({ type, data, timestamp: Date.now() });
}
close(): void {
this.channel.close();
}
}
export const sharedBroadcast = new SharedStateBroadcast();// Usage - syncs across tabs!
function CartIcon() {
const [count, setCount] = useState(0);
useEffect(() => {
const unsubscribe = sharedBroadcast.subscribe((message) => {
if (message.type === 'CART_UPDATED') {
setCount(message.data.count);
}
});
return unsubscribe;
}, []);
const updateCart = () => {
const newCount = count + 1;
setCount(newCount);
// Broadcast to other tabs
sharedBroadcast.publish('CART_UPDATED', { count: newCount });
};
return <div onClick={updateCart}>π {count}</div>;
}πΎ Pattern 5: LocalStorage + Events
Persist state and sync across MFEs.
// shared/local-storage-state.ts
class LocalStorageState {
private key: string;
constructor(key: string = 'mfe-shared-state') {
this.key = key;
}
get<T>(subKey: string): T | null {
const data = localStorage.getItem(this.key);
if (!data) return null;
try {
const parsed = JSON.parse(data);
return parsed[subKey] || null;
} catch {
return null;
}
}
set<T>(subKey: string, value: T): void {
const current = this.getAll();
const updated = { ...current, [subKey]: value };
localStorage.setItem(this.key, JSON.stringify(updated));
// Dispatch event to notify other components
window.dispatchEvent(new CustomEvent('mfe-storage-change', {
detail: { key: subKey, value }
}));
}
private getAll(): Record<string, any> {
const data = localStorage.getItem(this.key);
return data ? JSON.parse(data) : {};
}
subscribe(callback: (event: CustomEvent) => void): () => void {
window.addEventListener('mfe-storage-change', callback as EventListener);
return () => {
window.removeEventListener('mfe-storage-change', callback as EventListener);
};
}
}
export const localStorageState = new LocalStorageState();// Usage
function usePersistedState<T>(key: string, initialValue: T) {
const [state, setState] = useState<T>(() => {
return localStorageState.get<T>(key) || initialValue;
});
useEffect(() => {
const unsubscribe = localStorageState.subscribe((event: CustomEvent) => {
if (event.detail.key === key) {
setState(event.detail.value);
}
});
return unsubscribe;
}, [key]);
const setValue = (value: T) => {
setState(value);
localStorageState.set(key, value);
};
return [state, setValue] as const;
}
// In any MFE
function CartIcon() {
const [cartCount, setCartCount] = usePersistedState('cart-count', 0);
return <div onClick={() => setCartCount(cartCount + 1)}>π {cartCount}</div>;
}π― Comparison & Decision Guide
| Pattern | Coupling | Complexity | Type Safety | Persistence | Cross-Tab |
|---|---|---|---|---|---|
| Event Bus | Loose | Low | β | β | β |
| Zustand | Medium | Low | β | Optional | β |
| Jotai | Medium | Medium | β | Optional | β |
| Custom Events | Loose | Low | β | β | β |
| BroadcastChannel | Loose | Medium | β | β | β |
| LocalStorage | Loose | Low | β | β | β |
π‘ Best Practices
1. Keep Shared State Minimal
// β BAD: Sharing everything
const sharedState = {
products: [...], // Product MFE specific
cart: [...], // Shared β
user: {...}, // Shared β
filters: {...}, // Product MFE specific
checkout: {...}, // Checkout MFE specific
};
// β
GOOD: Only truly shared state
const sharedState = {
cart: [...],
user: {...},
theme: 'light'
};2. Define Clear Contracts
// shared/contracts.ts
export interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
export interface User {
id: string;
email: string;
name: string;
}
export interface SharedEvents {
'cart:item-added': CartItem;
'cart:item-removed': { id: string };
'user:logged-in': User;
'user:logged-out': void;
}3. Version Your Shared State
// shared/store.ts
const STORE_VERSION = '1.0.0';
export const useSharedStore = create<SharedState>((set) => ({
_version: STORE_VERSION,
// ... rest of state
}));
// Check version on MFE load
if (useSharedStore.getState()._version !== STORE_VERSION) {
console.warn('Store version mismatch!');
}4. Handle Stale State
// shared/store.ts with timestamps
export const useSharedStore = create<SharedState>((set) => ({
cart: [],
cartUpdatedAt: null,
addToCart: (item) => set((state) => ({
cart: [...state.cart, item],
cartUpdatedAt: Date.now()
})),
}));
// Check freshness
const cart = useSharedStore(state => state.cart);
const updatedAt = useSharedStore(state => state.cartUpdatedAt);
const isStale = Date.now() - updatedAt > 5 * 60 * 1000; // 5 minutes
if (isStale) {
// Refetch cart
}π’ Real-World Examples
Amazon
// Shared: User, Cart
// Event-based communication
// Each widget (MFE) is independentSpotify
// Shared: Player state, User
// Zustand-like store
// Player widget controls playback everywhereZalando
// Shared: Cart, User, Filters
// Custom event system
// Each team owns their MFEπ Key Takeaways
- Start with Event Bus - Simplest, loosest coupling
- Upgrade to Zustand when you need type safety
- Keep shared state minimal - Only truly global state
- Version your contracts - Prevent breaking changes
- Persist when needed - Use LocalStorage for cart, preferences
- Consider cross-tab - Use BroadcastChannel if needed
- Monitor state changes - Debug tools are essential
Choose based on your needs, not what's trendy. Simple is often better than sophisticated.