Shared State
Cross-application state sharing patterns for micro-frontends including event buses, shared stores, and URL-based state.
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 linesShared 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
| Approach | Coupling | Latency | Complexity |
|---|---|---|---|
| URL / query params | Lowest | Reload | Low |
| Custom DOM events | Low | Instant | Low |
| BroadcastChannel | Low | Instant | Low |
| Shared observable store | Medium | Instant | Medium |
| Server-side state (API) | Medium | Network round-trip | Medium |
| Direct module import | Highest | Instant | Low (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¤cy=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
- Single owner per state slice — one module writes; everyone else subscribes. Multiple writers cause race conditions and unpredictable state.
- 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.
- Unsubscribe on unmount — every subscription or event listener must be cleaned up when a micro-frontend unmounts to prevent leaks and ghost updates.
- 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
BehaviorSubjectorgetState()patterns. - 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.
- Version your event schemas — if the shape of
mf:cart-updatedchanges, apps built against the old schema will break. Add a version field or use additive changes only. - 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-changedfires before a micro-frontend mounts. Always support reading current state on mount. - Overloading the global event bus — routing dozens of high-frequency events through
windowcustom events causes performance issues. Use targeted channels for chatty communication. - Direct module imports between micro-frontends — importing from
@myorg/catalog/src/storecreates a build-time dependency and breaks independent deployment. Only import from shared contract packages. - Forgetting iFrame isolation —
windowcustom events and in-memory stores do not cross iFrame boundaries. If you use iFrames, you must bridge state viapostMessage. - 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 modules —
import { 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-changedbefore listeners are registered, the event is lost; useBehaviorSubjectorgetState()patterns so latecomers can read current state on mount.
Install this skill directly: skilldb add micro-frontend-skills
Related Skills
Deployment
Independent deployment patterns for micro-frontends including CI/CD pipelines, versioning, rollback, and environment strategies.
Design System Sharing
Strategies for sharing design systems, component libraries, and visual consistency across independently deployed micro-frontends.
Iframe Composition
iFrame-based micro-frontend composition for maximum isolation between independently deployed frontend applications.
Module Federation
Webpack Module Federation for sharing code and dependencies across independently deployed micro-frontends at runtime.
Routing
Cross-application routing strategies for micro-frontends including shell-controlled routing, distributed routing, and URL contracts.
Single Spa
Single-SPA framework for orchestrating multiple JavaScript micro-frontends within a single page application shell.