Cross-App State & Context Sharing #

Independently deployed micro-frontends still have to agree on some shared truth: who the user is, which tenant is active, whether dark mode is on, what’s in the cart. The hard part of cross-app state is not the wiring — it’s deciding how little to share, who owns each slice, and which transport moves it across boundaries without coupling teams back together.

This guide is for frontend architects and tech leads who already run (or are about to run) multiple remotes composed at runtime, and who keep hitting the same questions: should we put everything in one Redux store? Pass props down from the host? Use an event bus? Lean on the browser? The answer is usually “a deliberate mix,” and the rest of this page is about choosing that mix on purpose.

We cover when sharing is justified versus when autonomy wins, a concept map of ownership boundaries and propagation styles, the canonical shared-dependency config for state libraries, a worked event-bus-plus-snapshot bridge, team-topology implications, a head-to-head tradeoffs table for the dominant patterns, an adoption roadmap with go/no-go gates, the pitfalls that bite in production, and an FAQ. The detailed implementation guides linked throughout go deeper on each transport.

Cross-app state ownership and transports Three remotes each own private state; an event bus, a shared store, and shared context carry the small slice of state that crosses boundaries. Each remote owns its state; only a thin shared slice crosses boundaries Catalog remote private: filters, paging, view state Cart remote private: line items, promo codes Account remote private: form state, profile drafts Shared slice: session, tenant, theme, cart count Event bus async pub/sub loosest coupling events, not values Shared store single source strong consistency tightest coupling Shared context host injects read-mostly values framework-bound Pick the lightest transport that meets the consistency requirement.
Remotes keep their own state private; only a thin shared slice crosses boundaries, carried by the lightest transport that meets the consistency need.

Decision criteria: share state, or keep autonomy? #

The default in a micro-frontend system should be don’t share. Every shared piece of state is a contract between teams, and contracts slow independent deployment. Share only when the cost of not sharing — duplicated fetches, inconsistent UI, broken flows — clearly exceeds the coupling cost.

Use these thresholds to decide:

When in doubt, keep the data private and expose a narrow, documented interface. It is far easier to promote private state to shared later than to claw shared state back once five teams depend on its shape.

A concrete worked example: a cart-count badge in the host header reads from the cart remote. It tolerates being a few hundred milliseconds stale, has one logical writer and many readers, and never blocks a flow — so it belongs on an event bus, not a store. An auth token, by contrast, must be byte-identical in every remote at the moment a fetch fires, so it lives behind a single host-owned accessor. Same system, two slices, two transports, decided by the criteria above rather than by habit.

Core concept map #

Three ideas underpin every decision on this page: ownership boundaries, propagation style, and transport choice. Get these straight before you write a line of wiring code, because every later argument about “where should this live?” resolves to one of them.

State ownership boundaries #

Map each slice of state to exactly one owning remote — the single writer. Other remotes are readers or request changes through events. This mirrors bounded contexts from domain-driven design: the cart remote owns line items; the catalog remote owns filters; the host owns session and tenant. Ambiguous ownership is the root cause of most cross-app state bugs, because two remotes end up racing to write the same value.

A useful rule: one writer, many readers. If you find yourself wanting two writers for one slice, that slice probably belongs in a third place (often the host) with the two remotes emitting intent events. Write down the ownership map as a literal table — slice, owning team, writer, readers, transport — and keep it in the host repo. It becomes the artifact code review checks against, and it is the cheapest way to stop a “small” change in one remote from silently breaking another.

Synchronous vs asynchronous propagation #

Synchronous propagation means a reader gets the current value immediately — a function call, a context read, a store selector. It is simple and consistent but couples the reader to the producer’s API and runtime presence. If the producing remote hasn’t loaded yet, a synchronous read either blocks or returns nothing.

Asynchronous propagation means a reader subscribes and reacts to changes over time. It tolerates remotes loading and unloading independently, which is why it suits cross-team boundaries, but it introduces ordering, replay, and “what’s the value before the first event?” problems. A pure event bus has no memory: a remote that mounts after an event fired never sees it.

Most production systems combine both: a synchronous snapshot for the value at first render plus an asynchronous channel for subsequent updates. This snapshot-plus-stream pattern is the single most reliable shape for cross-app state, and it is worth implementing once as a tiny shared utility rather than ad hoc in every remote — the worked example below does exactly that.

Transport choices #

There are several transports, each with a dedicated guide:

A fourth, orthogonal option is to encapsulate state behind a custom element so it never leaks into a shared scope at all — useful when remotes use different frameworks. And when the “state” in question is the URL, route coordination is its own discipline, covered in routing coordination across micro-frontends.

Annotated config: sharing the state libraries #

If you do choose a shared store, the library that backs it must be a true singleton across remotes — two copies of Redux or Zustand each hold their own state, silently breaking sharing. This is a Module Federation shared concern, and it follows the same singleton discipline as managing shared dependencies at runtime.

In 2026, the recommended Webpack-side plugin is @module-federation/enhanced, which supersedes the bare webpack.container.ModuleFederationPlugin and adds runtime plugins, type sharing, and better singleton diagnostics. The shared block is shape-compatible.

// rspack.config.js / webpack.config.js — host and every remote use the same block
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      shared: {
        // React must be a singleton or context/hooks break across remotes.
        react: { singleton: true, requiredVersion: '^19.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^19.0.0' },

        // The shared store library: one instance for the whole page,
        // otherwise each remote gets its own store and sync silently fails.
        zustand: { singleton: true, requiredVersion: '^5.0.0' },

        // The shared *contract* — selectors, action types, the store factory.
        // Keep this package tiny and version it like a public API.
        '@acme/shared-state': {
          singleton: true,
          requiredVersion: '^1.0.0',
          // strictVersion: false lets a remote run with a compatible-but-newer
          // host version during a staggered rollout instead of crashing.
          strictVersion: false,
        },
      },
    }),
  ],
};

Two annotations matter most. First, singleton: true on the store library is non-negotiable — without it, sharing is an illusion. Second, the @acme/shared-state package should contain only the contract (the store’s shape, selectors, and action creators), not feature logic, so it changes rarely. Treat its version bumps as breaking-change events with the same care you’d give a REST API, including the version-tolerance strategies from the tradeoffs guides.

For Vite-based remotes, the equivalent block lives in @module-federation/vite (the actively maintained successor to @originjs/vite-plugin-federation for most teams) and follows the same singleton rules. Mixing Webpack/Rspack and Vite remotes is fine as long as the shared store library and its requiredVersion line up across both — the federation runtime negotiates a single highest-compatible copy at load time, so a ^5.0.0 Zustand in a Webpack remote and a 5.0.2 in a Vite remote resolve to one instance.

Worked example: an event bus with a synchronous snapshot #

The most common cross-app need — “everyone should react to a value that changes over time, and late-mounting remotes need the current value immediately” — is best solved by a tiny store-backed bus. It exposes a synchronous getSnapshot() for first render and an asynchronous subscribe() for updates, the exact shape React 18/19’s useSyncExternalStore expects. Ship it inside @acme/shared-state, mark it singleton: true, and every framework can consume it.

// @acme/shared-state/src/bus.ts — framework-agnostic, single instance per page
export type SharedSlice = {
  userId: string | null;
  tenantId: string | null;
  theme: 'light' | 'dark';
  cartCount: number;
};

type Listener = () => void;

function createSharedBus(initial: SharedSlice) {
  let state = initial;
  const listeners = new Set<Listener>();

  return {
    // Synchronous read — safe for first render, no "empty before first event".
    getSnapshot: (): SharedSlice => state,

    // Single-writer update. Owner code calls this; readers never do.
    set(patch: Partial<SharedSlice>) {
      const next = { ...state, ...patch };
      // Skip notify if nothing actually changed — prevents render loops.
      if (next.userId === state.userId && next.tenantId === state.tenantId &&
          next.theme === state.theme && next.cartCount === state.cartCount) return;
      state = next;
      listeners.forEach((l) => l());
    },

    // Async subscribe. Returns an unsubscribe handle — call it on unmount.
    subscribe(listener: Listener): () => void {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

// Guard against duplicate instances: if federation singleton is misconfigured,
// a second copy of this module would create a second bus and break sharing.
const g = globalThis as { __acmeBus__?: ReturnType<typeof createSharedBus> };
export const sharedBus =
  g.__acmeBus__ ?? (g.__acmeBus__ = createSharedBus({
    userId: null, tenantId: null, theme: 'light', cartCount: 0,
  }));

The globalThis guard is a deliberate belt-and-suspenders measure: singleton: true should already guarantee one copy, but pinning the instance to a well-known global key means that even if a remote misconfigures its shared block, it joins the existing bus instead of forking a private one. That single line turns the silent “two copies” failure into a non-event.

Reading it from a React remote is one hook, and because getSnapshot is synchronous, there is no flash of empty state when the remote mounts late:

// any React 18/19 remote — reads the shared slice with correct tearing semantics
import { useSyncExternalStore } from 'react';
import { sharedBus } from '@acme/shared-state';

export function useSharedSlice<T>(select: (s: typeof sharedBus extends
  { getSnapshot: () => infer S } ? S : never) => T): T {
  return useSyncExternalStore(
    sharedBus.subscribe,
    () => select(sharedBus.getSnapshot()),
    () => select(sharedBus.getSnapshot()), // server snapshot (SSR-safe)
  );
}

// In the header (a reader): re-renders only when cartCount changes.
function CartBadge() {
  const count = useSharedSlice((s) => s.cartCount);
  return <span aria-label={`${count} items in cart`}>{count}</span>;
}

The cart remote — the single writer for cartCount — calls sharedBus.set({ cartCount }) whenever its line items change, and never the other way around. If you need to reach a second browser tab (a cart open in two windows), wrap the same set/subscribe over BroadcastChannel; if you need replay or debounce, back it with an RxJS 7 BehaviorSubject, whose .getValue() already gives you the synchronous snapshot. The contract — getSnapshot plus subscribe — stays identical, which is the point: consumers don’t change when you swap the underlying channel.

Team topology & ownership #

Cross-app state is an organizational problem wearing a technical costume. The transport you can sustain is bounded by how your teams are structured.

The practical test: if changing the shape of shared state requires synchronized deploys across teams, your topology and your transport are mismatched. Move toward versioned event contracts or host-injected context so each remote can adopt changes on its own schedule. The clean way to enforce this is to make the contract package itself owned by exactly one team and gated by a contract test — the same discipline that keeps remote APIs from drifting, covered under managing cross-team coupling.

Strategic tradeoffs #

The primary patterns make different bets on consistency, coupling, and bundle cost. There is no universal winner — pick per slice of state, not per system.

Dimension Centralized store (RTK/Zustand) Pub/sub event bus Shared context (host-injected) Snapshot bus (useSyncExternalStore)
Coupling Tight — shared schema & library Loose — versioned event contracts Medium — shared provider API Loose — tiny shared contract module
Consistency Strong, synchronous Eventual, asynchronous Strong for read, on render Strong read + async updates
Best for High-write data, strict consistency (live cart internals) Cross-team notifications (login, navigate) Read-mostly values (user, theme, flags) Cross-cutting value many readers watch (cart count)
Cross-framework Hard — store library must be shared Easy — events are framework-agnostic Hard — provider is framework-bound Easy — plain functions, any framework
Bundle impact Heavy — full store library shared Light — native APIs or tiny lib None beyond the framework Negligible — a few KB
Debuggability Excellent — devtools, time travel Harder — trace events across apps Good — standard component tooling Good — single set chokepoint to log
Independent deploy Constrained by store schema version Strong — additive events are safe Strong if contract is stable Strong — additive slice fields are safe
Teardown risk Low — store outlives mounts Memory leaks if not unsubscribed Low — React handles lifecycle Low — hook returns unsubscribe
Late-mount value Available (store holds state) Missing unless replay added Available on first render Available via getSnapshot

Read this table as a menu. A realistic e-commerce host might use shared context for user and theme, a snapshot bus for the cart-count badge that catalog updates and the header reads, a fire-and-forget event bus for “checkout started” analytics signals, and a small Redux Toolkit store only inside the cart remote’s own boundary for its complex line-item logic — four patterns coexisting because each slice has different needs.

Adoption roadmap #

Introduce cross-app state sharing incrementally. Jumping straight to a shared global store is the most common and most expensive mistake.

1. Pilot — start with the loosest transport #

Pick one concrete need (e.g. the header badge should reflect cart count) and solve it with the snapshot bus or a host-injected context value. Resist building a global store. The goal is to learn your propagation requirements with the cheapest possible coupling. Document the slice fields, event names, or context keys as a contract from day one.

Go/no-go: proceed only if the pilot’s data genuinely has multiple independent consumers and clear single ownership. If it has one consumer, a prop or context value is the end state — stop here.

2. Guardrails — make the contract explicit #

Before a second team depends on shared state, codify it: a versioned @acme/shared-state (or event-schema) package, schema validation on payloads (a Zod or Valibot parse at the bus boundary), and a written ownership map (who writes each slice). Add the singleton shared config to every remote and verify in a smoke test that there is exactly one bus/store instance on the page — the globalThis guard above makes this assertable.

Go/no-go: do not scale until you can answer “who owns this slice?” and “what happens when its shape changes?” for every shared value, and a CI contract test fails when a writer changes a field a reader depends on.

3. Scale — promote to a store only where consistency demands it #

Now, and only now, introduce a shared store for the specific slices that need strong consistency and high write throughput. Keep everything else on events, context, and the snapshot bus. Add observability: trace state mutations and event flows so cross-app bugs are diagnosable in production, ideally tied into the distributed tracing you already run across remotes.

Go/no-go: if a proposed shared-store slice would require synchronized cross-team deploys to change, redesign it as a versioned event contract instead.

Common pitfalls #

One writer turns into many. Root cause: ownership was never assigned, so two remotes both write the same slice and race. Resolution: assign a single owning remote (or the host) per slice; other remotes emit intent events that the owner applies. In the snapshot-bus pattern, keep set callable only from owner code and have readers go through hooks — review against your ownership map.

Two copies of the store library. Root cause: a remote didn’t mark the store library singleton: true, so it bundled its own copy and silently kept private state. Resolution: set singleton: true everywhere, pin the instance to a globalThis key as shown above, and add a runtime assertion or smoke test that there’s exactly one instance. This is the same failure mode as duplicated React, just harder to spot because nothing throws.

Late-mounting remote sees no value. Root cause: a remote subscribes to a memoryless event bus (raw CustomEvent or a plain RxJS Subject) and mounts after the last event fired, so its UI shows empty until the next event. Resolution: back the channel with state — the snapshot bus, a BehaviorSubject, or a ReplaySubject(1) — so getSnapshot() always returns the current value at first render.

Memory leaks from un-torn-down subscriptions. Root cause: a remote subscribes to the bus or store on mount but never unsubscribes on unmount, leaving dangling callbacks that fire into dead components. Resolution: return an unsubscribe handle from every subscribe call and invoke it in cleanup (useSyncExternalStore and useEffect returns handle this automatically); verify with leak detection in CI.

Breaking the shared schema on a “small” change. Root cause: a renamed field in the shared slice or an altered event payload breaks consumers deployed at a different version. Resolution: version the contract, make changes additive (new fields, new events) rather than mutating existing ones, and validate payloads against a schema so mismatches fail loudly and early.

Render storms from over-broad selectors. Root cause: a reader subscribes to the whole shared slice instead of the one field it needs, so every unrelated update re-renders it. Resolution: select the narrowest value (useSharedSlice((s) => s.cartCount)), and make set skip notification when nothing changed, as the worked example does. With Redux Toolkit, reach for createSelector/useSelector with referential equality.

Race conditions during async hydration. Root cause: multiple remotes hydrate shared state concurrently and an out-of-order resolution overwrites a newer value with an older one. Resolution: attach a version or timestamp to each update and reject stale writes, or let a single owner serialize hydration.

Over-sharing to “save effort.” Root cause: a team shares a whole feature’s state because it’s convenient, dragging unrelated teams into its release cadence. Resolution: share the minimal slice with multiple genuine consumers; keep everything else private behind a narrow interface.

FAQ #

Should all our micro-frontends use one global Redux store?

Rarely. A single store every team writes to recreates the deployment bottleneck micro-frontends exist to avoid — any schema change blocks every remote. Use domain-scoped state with a single owner per slice, share only the thin cross-cutting slice, and reach for a store only where strong consistency genuinely requires it. The Redux synchronization guide covers how to scope and sync stores safely with Redux Toolkit.

How do I share state between remotes built on different frameworks?

Avoid framework-bound mechanisms (React context, Vue provide/inject) for cross-framework boundaries. Use the framework-agnostic snapshot bus or a plain event bus, or encapsulate state behind a custom element with its own internal state and expose it through attributes and DOM events. All keep the contract in plain web APIs that React, Vue, Svelte, or Angular can consume identically.

Is Module Federation itself a state-sharing mechanism?

No. Module Federation shares code and dependencies, not runtime values. It lets remotes share the same store library instance, but you still need a store, event bus, or context to move actual state. The federation layer’s job is making sure there’s one copy of that library, which is why singleton configuration matters — see managing shared dependencies at runtime.

How do I share an auth token across remotes without leaking it?

Treat it as the most tightly controlled shared value: a single owner (usually the host) holds it, exposes it through a narrow accessor or short-lived context value, and never broadcasts the raw token over a wide event channel or a BroadcastChannel other tabs can read. The dedicated guide on sharing authentication tokens securely across remote apps walks through the secure patterns.

How do I keep cross-app state from breaking independent deployments?

Version the contract and make changes additive. New fields and new event types are backward-compatible; renaming or removing them is not. Validate payloads against a schema (Zod/Valibot) so incompatibilities fail fast, and use strictVersion: false plus staggered rollout so a remote can run against a compatible-but-newer host without crashing.

When is prop drilling from the host actually the right answer?

When a value has exactly one consumer or a shallow component tree, passing it as a prop (or one context provider at the host) is simpler and cheaper than any transport. Only escalate to a bus or store when multiple independently deployed remotes need the data — the alternatives to prop drilling guide covers where that line sits.