web_dev

How to Manage Shared State Across Micro-Frontends Without Breaking Team Independence

Learn how to manage shared state in micro-frontend apps using event buses, Redux-style modules, and persistent cross-tab storage. Build scalable, independent UIs today.

How to Manage Shared State Across Micro-Frontends Without Breaking Team Independence

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.

Keywords: micro-frontend state management, shared state micro-frontends, micro-frontend architecture, state sharing between micro-frontends, micro-frontend communication patterns, event bus micro-frontend, cross micro-frontend state, micro-frontend Redux, managing global state micro-frontends, micro-frontend best practices, micro-frontend design patterns, distributed frontend architecture, frontend module federation state, micro-frontend localStorage persistence, cross-tab state synchronization, micro-frontend performance optimization, micro-frontend testing strategies, shared state management JavaScript, micro-frontend subscription cleanup, memory management micro-frontends, micro-frontend integration testing, global state JavaScript applications, micro-frontend scalability, frontend state synchronization, micro-frontend event-driven architecture, shared module micro-frontend, micro-frontend user session management, cart state micro-frontends, micro-frontend theme management, frontend architecture at scale, micro-frontend state persistence, JavaScript state bus pattern, micro-frontend deployment strategies, frontend team independence, micro-frontend observable state, state management without framework, vanilla JavaScript state management, micro-frontend module loading, dynamic import state management, frontend monorepo state sharing



Similar Posts
Blog Image
OAuth 2.0 and OpenID Connect: Secure Authentication for Modern Web Apps

Discover how OAuth 2.0 and OpenID Connect enhance web app security. Learn implementation tips, best practices, and code examples for robust authentication and authorization. Boost your app's security now!

Blog Image
Is WebAR the Game-Changer the Digital World Has Been Waiting For?

WebAR: The Browser-Based AR Revolution Transforming Digital Experiences Across Industries

Blog Image
**JWT Authentication Security Guide: Refresh Token Rotation and Production-Ready Implementation**

Learn how to build secure JWT authentication with refresh token rotation, automatic token handling, and protection against replay attacks. Implement production-ready auth systems.

Blog Image
WebAssembly Interface Types: Boost Your Web Apps with Multilingual Superpowers

WebAssembly Interface Types are a game-changer for web development. They act as a universal translator, allowing modules in different languages to work together seamlessly. This enables developers to use the best features of various languages in a single project, improving performance and code reusability. It's paving the way for a new era of polyglot web development.

Blog Image
How Safe Is Your Website from the Next Big Cyberattack?

Guardians of the Web: Merging Development with Cybersecurity's Relentless Vigilance

Blog Image
How Can Babel Make Your JavaScript Future-Proof?

Navigating JavaScript's Future: How Babel Bridges Modern Code with Ancient Browsers