Skip to main content
Technology & EngineeringMicro Frontend266 lines

Shared State

Cross-application state sharing patterns for micro-frontends including event buses, shared stores, and URL-based state.

Quick Summary18 lines
You are an expert in cross-application state sharing for micro-frontend architectures. You help teams design communication and data-sharing patterns that keep micro-frontends loosely coupled while enabling them to coordinate on shared concerns like authentication, user context, shopping carts, and UI state.

## Key Points

1. **Single owner per state slice** — one module writes; everyone else subscribes. Multiple writers cause race conditions and unpredictable state.
2. **Use an event/state contract package** — a small shared npm package with TypeScript interfaces for events and state shapes. This is the only coupling between teams.
3. **Unsubscribe on unmount** — every subscription or event listener must be cleaned up when a micro-frontend unmounts to prevent leaks and ghost updates.
5. **Minimize shared state surface** — share only what is truly cross-cutting. If only two apps need to coordinate, use a direct channel rather than a global store.
6. **Version your event schemas** — if the shape of `mf:cart-updated` changes, apps built against the old schema will break. Add a version field or use additive changes only.
7. **Avoid shared mutable objects** — always dispatch copies or immutable snapshots. If two apps hold references to the same mutable object, you get invisible coupling.
- **Stale subscribers after unmount** — if a micro-frontend does not unsubscribe, its callback fires against a detached DOM, causing errors or memory leaks.
- **Event ordering assumptions** — you cannot guarantee that `mf:user-changed` fires before a micro-frontend mounts. Always support reading current state on mount.
- **Overloading the global event bus** — routing dozens of high-frequency events through `window` custom events causes performance issues. Use targeted channels for chatty communication.
- **Forgetting iFrame isolation** — `window` custom events and in-memory stores do not cross iFrame boundaries. If you use iFrames, you must bridge state via `postMessage`.
- **Race conditions on startup** — if the shell dispatches events before micro-frontends have registered listeners, those events are lost. Implement a ready handshake or replay mechanism.
- **Having multiple writers for the same state slice** — two micro-frontends both updating the cart causes race conditions and unpredictable state; designate a single owner per state slice.
skilldb get micro-frontend-skills/Shared StateFull skill: 266 lines
Paste into your CLAUDE.md or agent config

Shared State — Micro-Frontends

You are an expert in cross-application state sharing for micro-frontend architectures. You help teams design communication and data-sharing patterns that keep micro-frontends loosely coupled while enabling them to coordinate on shared concerns like authentication, user context, shopping carts, and UI state.

Overview

In a micro-frontend architecture, each application owns its internal state. However, certain state must be shared: the authenticated user, locale preferences, a shopping cart, or filter selections that span multiple apps. The challenge is sharing this state without creating tight coupling that defeats the purpose of independent deployment.

The spectrum of approaches ranges from completely decoupled (URL-based, custom events) to more integrated (shared observable stores, server-side state). The right choice depends on isolation requirements, latency tolerance, and team coordination overhead.

Core Concepts

State Ownership

Every piece of shared state should have a single owner — one micro-frontend or utility module that is the source of truth. Other apps subscribe to or query that owner; they do not maintain their own copies.

Coupling Spectrum

ApproachCouplingLatencyComplexity
URL / query paramsLowestReloadLow
Custom DOM eventsLowInstantLow
BroadcastChannelLowInstantLow
Shared observable storeMediumInstantMedium
Server-side state (API)MediumNetwork round-tripMedium
Direct module importHighestInstantLow (but breaks independence)

Contracts Over Implementations

Shared state should be accessed through a defined API contract (TypeScript interface, event schema, URL parameter spec) — never by reaching into another app's internals.

Implementation Patterns

Custom Events (DOM-Based)

// Publisher — e.g., auth micro-frontend
function broadcastUserChange(user) {
  window.dispatchEvent(
    new CustomEvent("mf:user-changed", {
      detail: { user },
      bubbles: false,
    })
  );
}

// Subscriber — e.g., catalog micro-frontend
window.addEventListener("mf:user-changed", (event) => {
  const { user } = event.detail;
  updateUserContext(user);
});

Observable Shared Store (Utility Module)

// @myorg/shared-state — deployed as a utility module
import { BehaviorSubject, Observable } from "rxjs";
import { distinctUntilChanged, map } from "rxjs/operators";

interface AppState {
  user: User | null;
  locale: string;
  cartItemCount: number;
}

const state$ = new BehaviorSubject<AppState>({
  user: null,
  locale: "en",
  cartItemCount: 0,
});

export function getState(): AppState {
  return state$.getValue();
}

export function setState(partial: Partial<AppState>): void {
  state$.next({ ...state$.getValue(), ...partial });
}

export function select<K extends keyof AppState>(key: K): Observable<AppState[K]> {
  return state$.pipe(
    map((s) => s[key]),
    distinctUntilChanged()
  );
}
// catalog micro-frontend — subscribing
import { select } from "@myorg/shared-state";

const sub = select("locale").subscribe((locale) => {
  i18n.changeLanguage(locale);
});

// On unmount
sub.unsubscribe();

BroadcastChannel (Cross-Tab and Cross-Frame)

// Shared channel for cart updates
const cartChannel = new BroadcastChannel("mf:cart");

// Publisher (checkout app)
function updateCart(items) {
  const cartState = { items, total: calculateTotal(items) };
  cartChannel.postMessage({ type: "CART_UPDATED", payload: cartState });
}

// Subscriber (navbar app showing cart badge)
const cartChannel = new BroadcastChannel("mf:cart");
cartChannel.onmessage = (event) => {
  if (event.data.type === "CART_UPDATED") {
    renderCartBadge(event.data.payload.items.length);
  }
};

URL-Based State Sharing

// Shell router manages shared URL state
// URL: /catalog?locale=de&currency=EUR

function getSharedParams() {
  const params = new URLSearchParams(window.location.search);
  return {
    locale: params.get("locale") || "en",
    currency: params.get("currency") || "USD",
  };
}

function setSharedParam(key, value) {
  const url = new URL(window.location.href);
  url.searchParams.set(key, value);
  window.history.replaceState(null, "", url);
  // Notify micro-frontends
  window.dispatchEvent(new CustomEvent("mf:url-params-changed"));
}

Extracting Shared State into a React Context Bridge

// Shell provides context; micro-frontends consume it via a bridge
// shared-context-bridge.ts
import React, { createContext, useContext, useSyncExternalStore } from "react";
import { getState, select } from "@myorg/shared-state";

const SharedStateContext = createContext(getState());

export function SharedStateProvider({ children }: { children: React.ReactNode }) {
  const state = useSyncExternalStore(
    (callback) => {
      const sub = select("user").subscribe(callback);
      const sub2 = select("locale").subscribe(callback);
      return () => { sub.unsubscribe(); sub2.unsubscribe(); };
    },
    getState
  );
  return (
    <SharedStateContext.Provider value={state}>
      {children}
    </SharedStateContext.Provider>
  );
}

export function useSharedState() {
  return useContext(SharedStateContext);
}

Event Catalog (Schema Registry)

// @myorg/event-contracts — shared package defining all cross-app events

export interface MfEvents {
  "mf:user-changed": { user: User | null };
  "mf:locale-changed": { locale: string };
  "mf:cart-updated": { items: CartItem[]; total: number };
  "mf:navigate": { path: string; replace?: boolean };
  "mf:notification": { message: string; level: "info" | "warn" | "error" };
}

// Typed dispatcher
export function dispatch<K extends keyof MfEvents>(type: K, detail: MfEvents[K]) {
  window.dispatchEvent(new CustomEvent(type, { detail }));
}

// Typed listener
export function on<K extends keyof MfEvents>(
  type: K,
  handler: (detail: MfEvents[K]) => void
): () => void {
  const listener = (e: Event) => handler((e as CustomEvent).detail);
  window.addEventListener(type, listener);
  return () => window.removeEventListener(type, listener);
}

Server-Side State via API

// When micro-frontends cannot share in-memory state (e.g., iFrame isolation),
// use a lightweight API as the source of truth.

// shell fetches and injects initial state
async function loadSharedContext() {
  const response = await fetch("/api/context", {
    credentials: "include",
  });
  return response.json(); // { user, locale, featureFlags, ... }
}

// Each micro-frontend fetches on mount or receives via postMessage
// This avoids stale in-memory state across hard navigations

Best Practices

  1. Single owner per state slice — one module writes; everyone else subscribes. Multiple writers cause race conditions and unpredictable state.
  2. Use an event/state contract package — a small shared npm package with TypeScript interfaces for events and state shapes. This is the only coupling between teams.
  3. Unsubscribe on unmount — every subscription or event listener must be cleaned up when a micro-frontend unmounts to prevent leaks and ghost updates.
  4. Prefer pull over push for initial state — when a micro-frontend mounts, it should read the current state (pull), not rely on having received a past event (push). Use BehaviorSubject or getState() patterns.
  5. Minimize shared state surface — share only what is truly cross-cutting. If only two apps need to coordinate, use a direct channel rather than a global store.
  6. Version your event schemas — if the shape of mf:cart-updated changes, apps built against the old schema will break. Add a version field or use additive changes only.
  7. Avoid shared mutable objects — always dispatch copies or immutable snapshots. If two apps hold references to the same mutable object, you get invisible coupling.

Common Pitfalls

  • Stale subscribers after unmount — if a micro-frontend does not unsubscribe, its callback fires against a detached DOM, causing errors or memory leaks.
  • Event ordering assumptions — you cannot guarantee that mf:user-changed fires before a micro-frontend mounts. Always support reading current state on mount.
  • Overloading the global event bus — routing dozens of high-frequency events through window custom events causes performance issues. Use targeted channels for chatty communication.
  • Direct module imports between micro-frontends — importing from @myorg/catalog/src/store creates a build-time dependency and breaks independent deployment. Only import from shared contract packages.
  • Forgetting iFrame isolationwindow custom events and in-memory stores do not cross iFrame boundaries. If you use iFrames, you must bridge state via postMessage.
  • Race conditions on startup — if the shell dispatches events before micro-frontends have registered listeners, those events are lost. Implement a ready handshake or replay mechanism.

Core Philosophy

Shared state in a micro-frontend architecture should be the exception, not the rule. Each micro-frontend should own its internal state independently. Only truly cross-cutting concerns — authenticated user, locale, cart, feature flags — belong in a shared state layer. The more state you share, the more coupling you introduce, and coupling is what micro-frontends are designed to eliminate.

Every piece of shared state must have a single owner. One module writes; everyone else subscribes or queries. If two micro-frontends can both write to the same state slice, you get race conditions, stale reads, and debugging sessions that span multiple teams' codebases. Establish clear ownership: the auth module owns user state, the cart module owns cart state, the shell owns locale.

Use a contract package for shared events and state shapes. A small npm package containing TypeScript interfaces for event payloads and state types is the minimum coupling required for safe inter-app communication. This package is the only shared dependency between teams, and it should follow strict semantic versioning so that breaking changes are explicit and manageable.

Anti-Patterns

  • Sharing too much state — making every piece of application state globally accessible creates invisible coupling between teams; share only what is genuinely cross-cutting and let each micro-frontend own its internal state.

  • Having multiple writers for the same state slice — two micro-frontends both updating the cart causes race conditions and unpredictable state; designate a single owner per state slice.

  • Importing directly from another micro-frontend's internal modulesimport { store } from '@myorg/catalog/src/store' creates a build-time dependency that breaks independent deployment; only import from shared contract packages.

  • Not unsubscribing on unmount — failing to clean up event listeners and store subscriptions when a micro-frontend unmounts causes memory leaks and ghost updates to detached DOM elements.

  • Dispatching events before micro-frontends are ready — if the shell fires mf:user-changed before listeners are registered, the event is lost; use BehaviorSubject or getState() patterns so latecomers can read current state on mount.

Install this skill directly: skilldb add micro-frontend-skills

Get CLI access →