Front-end Engineering Lab
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?

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.

// 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

PatternCouplingComplexityType SafetyPersistenceCross-Tab
Event BusLooseLow❌❌❌
ZustandMediumLowβœ…Optional❌
JotaiMediumMediumβœ…Optional❌
Custom EventsLooseLow❌❌❌
BroadcastChannelLooseMediumβŒβŒβœ…
LocalStorageLooseLowβŒβœ…βœ…

πŸ’‘ 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 independent

Spotify

// Shared: Player state, User
// Zustand-like store
// Player widget controls playback everywhere

Zalando

// Shared: Cart, User, Filters
// Custom event system
// Each team owns their MFE

πŸ“š Key Takeaways

  1. Start with Event Bus - Simplest, loosest coupling
  2. Upgrade to Zustand when you need type safety
  3. Keep shared state minimal - Only truly global state
  4. Version your contracts - Prevent breaking changes
  5. Persist when needed - Use LocalStorage for cart, preferences
  6. Consider cross-tab - Use BroadcastChannel if needed
  7. Monitor state changes - Debug tools are essential

Choose based on your needs, not what's trendy. Simple is often better than sophisticated.

On this page