Final StretchOrdered learning track

Microfrontend State Boundaries

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 103

Microfrontend state boundaries for large React systems: ownership, isolation, shared dependencies, URL contracts, event bridges, server-state rendezvous, and failure modes.

14 min read2701 words
PrevNext
Lesson 103123 lesson track102–123 Final Stretch
#react#microfrontend#state-management#architecture+3 more

Part 103 — Microfrontend State Boundaries

Microfrontend architecture is not a state-management technique.

It is a delivery and ownership architecture: multiple independently built, released, and owned frontend units cooperate inside one user experience. If the state model is not explicit, microfrontends usually collapse into one of two failures:

  1. Distributed monolith — many deployables, one tangled runtime state graph.
  2. Fragmented UX — independent pieces that cannot coordinate navigation, permissions, cache freshness, or workflow intent.

The goal of this part is to design state boundaries so microfrontends can collaborate without turning the browser into an ungoverned distributed system.


1. The Core Mental Model

A microfrontend boundary is valid only if it answers four questions:

Who owns this state?
Who is allowed to mutate it?
How does another frontend observe it?
What happens when another frontend is on a different version?

If those questions are unclear, the boundary is not architecture. It is a bundle-splitting trick.

The most important rule:

Do not make shared browser memory your default integration mechanism.

Prefer explicit integration contracts: URL, server state, typed event bridge, capability provider, or stable utility API.


2. Microfrontend Is a Boundary, Not a License to Share Everything

A React monolith can use props, context, reducers, stores, and query cache inside one coherent runtime. A microfrontend system adds independent build and release cycles. That changes the risk profile.

Inside one React app, this is acceptable:

<PermissionProvider>
  <CaseSearchPage />
</PermissionProvider>

Across independently deployed microfrontends, this is dangerous if treated casually:

// shell
<GlobalAppContext.Provider value={everything}>
  <RemoteCaseSearch />
  <RemotePenaltyEditor />
</GlobalAppContext.Provider>

Why?

Because the remote is now coupled to:

  • provider shape
  • provider version
  • provider lifecycle
  • provider rendering semantics
  • state update frequency
  • React runtime compatibility
  • release coordination between shell and remote

That is not integration. That is hidden shared implementation.


3. State Taxonomy in Microfrontend Systems

Before choosing a communication pattern, classify the state.

State KindExampleCorrect OwnerSharing Mechanism
Local UI statedropdown open, row hover, local tabowning component/remotedo not share
Local workflow statewizard step inside one remoteowning remoteinternal reducer/machine
Navigation stateselected case id, filter queryrouter/shell/URLURL params/search params
Server statecase detail, tasks, commentsbackend/APIquery cache per remote or shared API contract
Identity/sessioncurrent user, token expiryshell/auth capabilityutility module/capability API
Permission decisioncan approve, can edit penaltybackend + capability providerdecision object, not raw role dump
Cross-MFE intentopen case drawer, refresh badgeevent contracttyped event bridge
Design-system statetheme, density, directionshell/design systemcontext or CSS variables
Global workflowcase lifecycle spanning remotesbackend/workflow serviceserver state + event notifications

The mistake is treating all of these as “global state”. They are not global in the same way.


4. Non-Negotiable Invariants

For microfrontend state, use these invariants as hard rules.

Invariant 1 — A remote owns its internal interaction state

A remote should not leak internal UI state unless another app has a real product reason to observe it.

Bad:

window.globalStore.caseSearch = {
  selectedRows,
  expandedRows,
  visibleColumns,
  internalDraftFilters,
};

Good:

// Remote owns internal table state.
// Only committed navigation/filter state becomes URL state.
/cases?status=open&assignee=me&page=2

Invariant 2 — Cross-boundary state must be versioned

A typed event or utility API is still an API. It needs a versioning strategy.

type CaseOpenedV1 = {
  type: 'case.opened';
  version: 1;
  payload: {
    caseId: string;
    source: 'search' | 'task-list' | 'notification';
  };
};

Invariant 3 — Cross-boundary mutation must be explicit

A remote should not directly mutate another remote's state.

Bad:

window.penaltyEditorStore.getState().setDraftAmount(5000);

Good:

eventBridge.publish({
  type: 'penalty.editRequested',
  version: 1,
  payload: { caseId, proposedAmount: 5000 },
});

The receiving remote decides whether it can honor the request.

Invariant 4 — Server state is synchronized through server authority

If two remotes display the same case summary, the backend owns the truth. The browser cache is a performance layer, not authority.

Notice that the event does not carry the entire new canonical case object. It carries an invalidation signal or intent. The API remains authority.


5. Integration Patterns, Ranked by Safety

Pattern 1 — URL as State Contract

Use the URL for state that should survive reload, support share/back/forward, or identify the current resource/view.

Good candidates:

  • selected entity id
  • active tab when meaningful
  • search filters
  • pagination cursor
  • route-level mode such as review, edit, audit

Example:

/enforcement/cases/C-123?tab=penalties&drawer=activity

Mermaid view:

Use URL state when the state is:

  • meaningful to the user
  • serializable
  • stable enough to bookmark
  • safe to expose in browser history/logs

Do not put these in URL:

  • secrets
  • personally sensitive temporary data
  • large drafts
  • high-frequency UI state
  • optimistic temporary internals

Pattern 2 — Server State as Rendezvous

When multiple remotes care about the same business entity, prefer server-state coordination.

Each remote may have its own TanStack Query client or cache, but they should share:

  • API contract
  • query key schema if using a shared query utility
  • invalidation event convention
  • entity version/ETag policy when concurrency matters

Example:

// shared query key factory as public utility API
export const caseKeys = {
  all: ['cases'] as const,
  detail: (caseId: string) => ['cases', 'detail', caseId] as const,
  tasks: (caseId: string) => ['cases', 'detail', caseId, 'tasks'] as const,
};

The key factory can be shared. The cache instance does not have to be shared.

That distinction matters.

Shared key factory = stable integration contract. Shared cache singleton = runtime coupling.


Pattern 3 — Capability Utility Module

A utility module is useful for platform capabilities that many microfrontends need but should not own.

Examples:

  • authentication/session access
  • permission decision API
  • analytics client
  • logger
  • feature flag reader
  • API client factory
  • design-system tokens
  • route helpers

Good utility API:

export type PermissionDecision = {
  allowed: boolean;
  reason?: 'missing_permission' | 'case_locked' | 'status_not_allowed';
};

export function canApproveCase(input: {
  caseId: string;
  caseStatus: string;
}): Promise<PermissionDecision>;

Bad utility API:

export const globalUserObject = mutableEverything;
export const globalReduxStore = store;

A utility module should expose capabilities, not uncontrolled mutable state.


Pattern 4 — Typed Event Bridge

Use an event bridge for cross-boundary intent and notification.

Good events:

  • case.openRequested
  • case.updated
  • notification.countInvalidated
  • modal.openRequested
  • audit.logRequested

Bad events:

  • setSelectedCaseId
  • updateRemoteStore
  • toggleThatOtherComponent
  • contextChanged

An event bridge should not smuggle implementation details.

A minimal event bridge:

type AppEvent =
  | {
      type: 'case.openRequested';
      version: 1;
      payload: { caseId: string; source: string };
    }
  | {
      type: 'case.updated';
      version: 1;
      payload: { caseId: string; reason: 'penalty_changed' | 'status_changed' };
    };

type Handler<T extends AppEvent['type']> = (
  event: Extract<AppEvent, { type: T }>
) => void;

class EventBridge {
  private target = new EventTarget();

  publish(event: AppEvent) {
    this.target.dispatchEvent(
      new CustomEvent(event.type, { detail: event })
    );
  }

  subscribe<T extends AppEvent['type']>(type: T, handler: Handler<T>) {
    const listener = (raw: Event) => {
      const event = (raw as CustomEvent).detail;
      handler(event);
    };

    this.target.addEventListener(type, listener);

    return () => {
      this.target.removeEventListener(type, listener);
    };
  }
}

React adapter:

function useAppEvent<T extends AppEvent['type']>(
  bridge: EventBridge,
  type: T,
  handler: Handler<T>
) {
  useEffect(() => {
    return bridge.subscribe(type, handler);
  }, [bridge, type, handler]);
}

Production version should also include:

  • runtime validation
  • event version handling
  • logging/breadcrumbs
  • dead-letter handling for invalid events
  • ownership documentation
  • backward compatibility policy

Pattern 5 — Shared External Store

This is the most dangerous pattern and should be rare.

A shared external store may be acceptable for:

  • shell-level ephemeral state such as active global command palette
  • design-system shell state like density/sidebar collapse
  • platform notification count if explicitly owned by shell
  • session-level non-sensitive read model

It is usually wrong for:

  • domain entity data
  • form drafts
  • server authority state
  • remote internals
  • workflow state that belongs to one feature

If used, enforce these constraints:

- store has one named owner
- exposed API is narrow
- no remote imports internal slice directly
- all mutation methods are commands
- store shape is versioned
- subscriptions are selector-based
- SSR/hydration behavior is documented
- tests cover two versions of consuming remotes

6. Module Federation State Boundary Concerns

Module Federation allows independent builds to consume exposed modules from other builds at runtime. This is powerful, but it creates specific state risks.

Risk 1 — Shared singleton dependency drift

React should normally be shared as a singleton in a federated React system. Multiple React copies can cause invalid hook call errors or incompatible context boundaries.

But singleton sharing also creates release coupling:

Remote A expects React behavior/version X.
Remote B expects React behavior/version Y.
Shell provides React version Z.

Mitigation:

  • define supported React version range
  • keep shell and remotes inside a compatibility window
  • test remotes against shell-provided shared dependencies
  • avoid relying on undocumented internals
  • do not expose hooks/components compiled against incompatible runtime assumptions

Risk 2 — Remote imports become hidden architecture

Bad:

import { internalCaseStore } from 'caseRemote/internal/state';

Good:

import { openCase, caseEvents } from '@platform/case-contract';

Expose contracts, not internals.

Risk 3 — Runtime loading failure becomes product state failure

Remote loading can fail. That failure must be modeled in UI state.

<Suspense fallback={<RemoteSkeleton />}>
  <ErrorBoundary fallback={<RemoteUnavailable feature="Penalty Editor" />}>
    <PenaltyRemote caseId={caseId} />
  </ErrorBoundary>
</Suspense>

The shell must not assume every remote is always present.


7. single-spa State Boundary Concerns

single-spa commonly separates microfrontends into applications, parcels, and utility modules.

The state implication:

  • application owns a route/area lifecycle
  • parcel is embeddable UI with mount/unmount lifecycle
  • utility module exposes reusable functions/stateful capabilities

The trap is putting all shared state into utility modules.

A utility module is a good home for an API client factory. It is not automatically a good home for mutable workflow state.

Utility module rule:

Export stable capabilities. Avoid exporting mutable implementation state unless that state is explicitly platform-owned.


For a production React microfrontend system, use this layered model.

Shell owns

  • runtime composition
  • route table
  • global layout
  • auth/session capability
  • global error boundary
  • remote loading state
  • platform event bridge
  • design-system root providers

Remote owns

  • feature UI state
  • feature workflow state
  • feature query hooks
  • internal reducers/machines
  • component composition
  • feature-level error/loading UX

Backend owns

  • canonical business state
  • authorization decision
  • workflow lifecycle authority
  • audit trail
  • concurrency/version check

Shared platform owns

  • typed contracts
  • event schema
  • API client factory
  • observability client
  • design system primitives
  • permission client

9. Cross-Microfrontend Communication Matrix

Communication NeedPreferAvoid
Navigate to entityURL/router commandwriting remote state directly
Refresh data after mutationinvalidation event + API refetchbroadcasting full mutable entity dump
Open global modalshell overlay capabilityremote importing shell internals
Share current userauth utility/capabilitymutable window.user object
Share permissionpermission decision APIraw role arrays everywhere
Share table selectionusually do not shareglobal selected row store
Coordinate wizard across remotesbackend workflow + route statecross-remote reducer
Share design themeCSS variables/context from shellper-remote incompatible theme state
Publish analyticsplatform analytics clienteach remote inventing event schema
Microfrontend loaded/unloadedlifecycle eventsassuming remote is always present

10. Example: Enforcement Case Platform

Assume a regulatory case-management UI:

  • Shell
  • Case Search remote
  • Case Detail remote
  • Penalty Editor remote
  • Activity Timeline remote
  • Notification remote

Incorrect state design

Problems:

  • every remote can mutate everything
  • release coupling is hidden
  • no clear authority
  • stale data is indistinguishable from canonical data
  • auditability is poor
  • permission drift is likely
  • tests require booting the world

Better state design

Mutation flow:

This keeps business truth in the backend and uses events as coordination hints.


11. State Boundary Design by Lifecycle

Remote-local lifecycle

Use component state, reducer, context, or machine inside the remote.

Examples:

  • active row
  • expanded accordion
  • local validation state
  • unsaved draft in one feature
  • local modal inside the remote

Route lifecycle

Use URL state.

Examples:

  • caseId
  • selected tab
  • committed filters
  • page/cursor
  • mode: view, edit, audit

Session lifecycle

Use shell/platform capability.

Examples:

  • current user summary
  • permission client
  • feature flag client
  • global navigation state

Entity lifecycle

Use server state.

Examples:

  • case detail
  • list data
  • task count
  • comments
  • workflow status

Cross-version lifecycle

Use versioned contracts.

Examples:

  • event schema
  • route schema
  • utility API
  • exposed component props

12. Public Contract Design

Every cross-MFE boundary should have a public contract.

Route contract

type CaseRoute = {
  pathname: '/cases/:caseId';
  params: { caseId: string };
  search?: {
    tab?: 'summary' | 'penalties' | 'activity';
    drawer?: 'timeline' | 'notes';
  };
};

Event contract

type CaseEvents =
  | {
      type: 'case.updated';
      version: 1;
      payload: {
        caseId: string;
        reason: 'status_changed' | 'penalty_changed' | 'assignment_changed';
      };
    }
  | {
      type: 'case.openRequested';
      version: 1;
      payload: {
        caseId: string;
        source: string;
      };
    };

Capability contract

type PlatformCapabilities = {
  auth: {
    getUser(): Promise<UserSummary>;
    getAccessToken(): Promise<string>;
  };
  permissions: {
    can(input: PermissionInput): Promise<PermissionDecision>;
  };
  analytics: {
    track(event: AnalyticsEvent): void;
  };
  overlays: {
    confirm(input: ConfirmRequest): Promise<ConfirmResult>;
  };
};

Remote mount contract

type CaseDetailRemoteProps = {
  caseId: string;
  capabilities: PlatformCapabilities;
  eventBridge: EventBridge;
  onNavigate?: (target: AppRoute) => void;
};

Narrow contracts survive version drift better than broad shared objects.


13. Shared Query Cache: Usually Avoid

A shared QueryClient across remotes looks attractive.

<QueryClientProvider client={globalQueryClient}>
  <RemoteA />
  <RemoteB />
</QueryClientProvider>

It may work in a tightly coordinated monorepo. But in independently deployed remotes, it can create hidden coupling:

  • query key conflicts
  • stale time assumptions differ
  • mutation invalidation blast radius is unclear
  • cache memory is shared across unknown features
  • one remote can remove/invalidate another remote's cache accidentally
  • versioned query shape changes can break another remote

Safer default:

Shared API contract + shared query key factory + remote-owned QueryClient.

Use a shared QueryClient only if:

  • remotes are version-locked
  • query key namespace is governed
  • cache ownership is documented
  • integration tests cover mixed remotes
  • the shell explicitly owns server-state cache policy

14. Shared Redux/Zustand Store: Even More Rare

A shared store across remotes is usually a distributed monolith.

Bad smell:

// every remote imports the same global store
import { useGlobalStore } from '@platform/store';

Better:

// platform exposes command/query capabilities
const canApprove = await capabilities.permissions.can({
  action: 'case.approve',
  resourceId: caseId,
});

If you truly need a shared store, expose domain-specific selectors and commands:

type ShellStateApi = {
  useSidebarCollapsed(): boolean;
  setSidebarCollapsed(value: boolean): void;
  useCommandPaletteOpen(): boolean;
  openCommandPalette(): void;
  closeCommandPalette(): void;
};

Do not expose raw root state.


15. Versioning Strategy

Microfrontend state contracts must survive mixed versions.

Backward-compatible changes

Usually safe:

  • adding optional event field
  • adding new event type
  • adding new route search param with default behavior
  • adding new optional capability method

Breaking changes

Dangerous:

  • renaming event type
  • changing payload meaning
  • changing required prop
  • changing route param semantics
  • changing default permission interpretation
  • changing query response shape without API versioning

Compatibility policy

- Shell supports N and N-1 remote contracts.
- Platform utility APIs are semvered.
- Events include version field.
- Unknown event fields are ignored.
- Unknown event versions are rejected or routed to fallback.
- Removal requires deprecation window.

16. Testing Strategy

Microfrontend state boundaries need contract tests more than snapshot tests.

Unit tests

  • remote reducer/machine
  • URL parse/serialize
  • event payload validation
  • capability adapter behavior
  • query key factory

Contract tests

  • remote accepts documented props
  • remote emits documented events
  • shell handles remote unavailable
  • utility API preserves backward compatibility
  • event schema supports N-1 version

Integration tests

  • shell + remote composition
  • mutation invalidates another remote
  • route change updates all interested remotes
  • auth expiry affects all remotes consistently
  • permission change disables/blocks actions

Chaos tests

  • remote fails to load
  • remote loads slow
  • utility module unavailable
  • stale remote emits older event version
  • duplicate event is published
  • event arrives before subscriber mounts
  • backend mutation succeeds but invalidation event fails

17. Observability

Cross-boundary bugs are hard because no single component owns the whole chain.

Log boundary events with correlation IDs.

eventBridge.publish({
  type: 'case.updated',
  version: 1,
  correlationId: commandId,
  payload: { caseId, reason: 'penalty_changed' },
});

Recommended observability dimensions:

  • remote name
  • remote version
  • shell version
  • event type/version
  • route
  • user/session id hash
  • correlation id
  • query key affected
  • command id
  • backend request id
  • permission decision reason

Without this, debugging cross-MFE state bugs becomes guesswork.


18. Failure Modes

Failure Mode 1 — Global store becomes integration layer

Symptom:

  • every remote imports @platform/store
  • unrelated releases break each other
  • no one knows who owns state fields

Fix:

  • split platform capabilities from feature state
  • move entity truth to server state
  • expose commands/selectors, not root state
  • write ADR for remaining shared store

Failure Mode 2 — Event bus becomes command soup

Symptom:

remoteA.setFoo
remoteB.toggleBar
remoteC.updateWhatever

Fix:

  • rename events as product/domain intents
  • validate payload
  • define owner for each event
  • distinguish notification event from command event

Failure Mode 3 — URL becomes untyped dumping ground

Symptom:

  • query params are inconsistent
  • browser back breaks state
  • sensitive state leaks into URL

Fix:

  • create route/search schema
  • parse and canonicalize
  • reject unknown/invalid values
  • define privacy rules

Failure Mode 4 — Shared dependencies drift

Symptom:

  • invalid hook call
  • context not crossing boundary
  • duplicate React instance
  • remote works locally but fails in shell

Fix:

  • enforce singleton React where appropriate
  • test remote in shell compatibility harness
  • document supported version range
  • avoid exposing internals compiled against unstable assumptions

Failure Mode 5 — Cache invalidation is inconsistent

Symptom:

  • one remote updates entity
  • another remote shows stale data
  • user sees conflicting status

Fix:

  • define mutation impact map
  • publish invalidation event
  • refetch canonical server state
  • use entity version/ETag where needed

Failure Mode 6 — Permission state drifts

Symptom:

  • button enabled in one remote, disabled in another
  • backend rejects action user could start
  • audit trail lacks precondition reason

Fix:

  • centralize permission decision capability
  • include resource state in permission input
  • handle stale permission as expected error
  • never trust frontend as final authorization

19. Review Checklist

Before approving a microfrontend state boundary, ask:

[ ] Is this state local, URL, server, capability, workflow, or cross-app event?
[ ] Who owns it?
[ ] Who can mutate it?
[ ] Is the contract versioned?
[ ] Does it survive mixed remote versions?
[ ] Does it survive remote loading failure?
[ ] Is sensitive state kept out of URL/localStorage?
[ ] Is server state refetched from canonical authority?
[ ] Is there a correlation id for cross-boundary flows?
[ ] Is there a contract test?
[ ] Is there an ADR for non-obvious sharing?

20. Practical Rule of Thumb

Use this hierarchy:

Can it stay local? Keep it local.
Must it be shareable/bookmarkable? Put it in URL.
Is it canonical business data? Fetch from server/cache.
Is it a platform capability? Expose a narrow utility/capability API.
Is it cross-app intent? Publish a typed event.
Is it truly shared browser state? Use a governed external store as a last resort.

Microfrontend state design is mostly about restraint.

The best boundary is the one that lets teams ship independently while keeping business truth, user intent, and runtime state legible.


References

Lesson Recap

You just completed lesson 103 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.