We've all been there: you set up Module Federation, split your app into micro-frontends, and suddenly your Zustand store updates in one module but not another. Or worse—your TanStack Query cache fetches the same user profile three times because each remote thinks it's alone in the world.
The patterns that work beautifully in monolithic React apps break down in federated architectures. Context providers don't cross module boundaries. Stores instantiate twice. Cache invalidation becomes a distributed systems problem you didn't sign up for.
This guide covers the patterns that actually work in production—singleton configuration that prevents duplicate instances, cache-sharing strategies that don't create tight coupling, and the critical separation between client state (Zustand) and server state (TanStack Query) that makes federated apps maintainable. These aren't theoretical recommendations; they're lessons from teams at Zalando, PayPal, and other organizations running Module Federation at scale.
Why Federated State Breaks: The Memory Model Problem
In a monolithic SPA, memory is a contiguous, shared resource. A Redux store or React Context provider instantiated at the root is universally accessible. In a federated system, the application is composed of distinct JavaScript bundles—often developed by different teams, deployed at different times, and loaded asynchronously at runtime. These bundles execute within the same browser tab, yet they're separated by distinct closure scopes and dependency trees.
The root cause of most Module Federation state issues is deceptively simple: without explicit singleton configuration, each federated module gets its own instance of React, Redux, or Zustand. Users experience this as authentication that works in one section but not another, theme toggles that affect only part of the interface, or cart items that vanish when navigating between micro-frontends.
The Share Scope Engine
The engine powering state sharing is the __webpack_share_scopes__ global object—an internal Webpack API that acts as a registry for all shared modules in the browser session.
When the Host application bootstraps, it initializes entries in __webpack_share_scopes__.default for every library marked as shared. Each entry contains the version number and factory function to load the module. When a Remote application initializes, it performs a handshake: inspecting the share scope, comparing available versions against its requirements, and using semantic versioning resolution to determine compatibility.
If the Host provides React 18.2.0 and the Remote requires ^18.0.0, the runtime determines compatibility and the Remote uses the Host's reference. This "Reference Sharing" ensures that when the Remote calls React.useContext, it accesses the exact same Context Registry as the Host. If this handshake fails, the Remote loads its own React, creating a parallel universe where the Host's providers don't exist.
Configuration for Singleton Enforcement
The fix requires explicit singleton configuration in webpack:
// webpack.config.js - Every federated module needs this
const deps = require('./package.json').dependencies;
new ModuleFederationPlugin({
shared: {
react: {
singleton: true,
requiredVersion: deps.react,
strictVersion: true
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
strictVersion: true
},
zustand: { singleton: true, requiredVersion: deps.zustand },
'@tanstack/react-query': {
singleton: true,
requiredVersion: deps['@tanstack/react-query']
},
},
})
Three configuration properties matter most: singleton: true ensures only one instance exists across all federated modules, strictVersion: true throws errors when versions conflict rather than silently loading duplicates, and requiredVersion with explicit semver ranges prevents deployment failures. Dynamically loading versions from package.json ensures configuration matches installed packages.
The Async Boundary Pattern
The "Shared module is not available for eager consumption" error plagues new Module Federation setups. Standard entry points import React synchronously, but shared modules load asynchronously. The runtime hasn't initialized the share scope before the import executes.
Setting eager: true forces the library into the initial bundle, solving the error but adding ~100-150KB gzipped to Time to Interactive. The architectural best practice is establishing an asynchronous boundary:
// index.js - Simple wrapper enabling async loading
import('./bootstrap');
// bootstrap.js - Your actual application entry
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
When Webpack sees import('./bootstrap'), it creates a promise. During resolution, the Federation runtime initializes __webpack_share_scopes__, checks remote entry points, and ensures shared dependencies are ready. By the time bootstrap.js executes, React is available in the shared scope.
Client State: Zustand vs Redux in Federated Architectures
Zustand has emerged as the preferred client state library for micro-frontends due to its 1KB bundle size, singleton-friendly architecture, and absence of provider hierarchy requirements. Unlike Redux or Context, a Zustand store doesn't require wrapping components in providers that must align across module boundaries.
The "Initial Value Only" Bug
Developers migrating to federated architectures often encounter a baffling bug: the Remote application renders initial state correctly, but when the Host updates state, the Remote fails to re-render. It appears "stuck" on the initial value.
This highlights the difference between sharing code and sharing instances. If both Host and Remote import a store via build-time aliases, the bundler includes the code in both bundles. At runtime, the Host creates Store_Instance_A, the Remote creates Store_Instance_B. The Host updates Instance A; the Remote listens to Instance B. No update propagates.
The Exported Store Pattern
To guarantee synchronization, ensure the exact same JavaScript object in memory is used by both Host and Remote:
// Remote module exposes its store
// cart-remote/webpack.config.js
exposes: {
'./CartStore': './src/stores/cartStore',
'./CartComponent': './src/components/Cart',
}
// libs/shared/data-access/src/lib/cart.store.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
export const useCartStore = create(
devtools(
persist(
(set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
clearCart: () => set({ items: [] }),
}),
{ name: 'cart-storage' }
),
{ name: 'CartStore' }
)
);
// Export atomic selectors (prevents unnecessary re-renders)
export const useCartItems = () => useCartStore((state) => state.items);
export const useCartActions = () => useCartStore((state) => state.actions);
The Remote imports from the federation namespace, not the local library:
// remote/src/App.tsx
import { useCartStore } from 'host/CartStore';
export const RemoteApp = () => {
const items = useCartStore((state) => state.items);
return <div>{items.length} items in cart</div>;
};
When remote/App.tsx imports host/CartStore, Webpack delegates loading to the Federation runtime, which returns the module reference the Host already loaded. The useCartStore refers to the exact closure created in the Host.
The Dependency Injection Pattern for Redux
For Redux-based architectures with Provider requirements, wrapping Remotes in their own <Provider store={store}> creates dangerous nested providers. A cleaner pattern is dependency injection:
// Remote declares store as a prop contract
interface Props {
store: StoreType;
}
const RemoteWidget = ({ store }: Props) => {
const state = useStore(store);
return <div>{state.value}</div>;
};
export default RemoteWidget;
// Host passes its store instance
const RemoteWidget = React.lazy(() => import('remote/Widget'));
const App = () => {
return (
<Suspense fallback="Loading...">
<RemoteWidget store={myStore} />
</Suspense>
);
};
This decouples the Remote from store location, enables testing with mock stores, and ensures ownership remains with the Host.
Server State: Cache Sharing Strategies
TanStack Query v5 fundamentally changed cache sharing by removing the contextSharing prop. The current approach requires explicit QueryClient sharing via Module Federation exposes.
Strategy A: Shared QueryClient (Monolithic Cache)
The Host initializes a single QueryClient and shares it:
// host/src/queryClient.ts (exposed via Module Federation)
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
},
},
});
// Remote modules consume the shared client
const queryClient = await import('host/QueryClient');
function RemoteApp() {
return (
<QueryClientProvider client={queryClient.default}>
<ProductList />
</QueryClientProvider>
);
}
Advantages: Instant cache deduplication—if the Host fetches ['user', 'me'] and the Remote needs the same data, TanStack Query serves it from cache without a network request. Global invalidation means mutations in one module update all consumers.
Risk: This requires successful Context sharing. If React instances differ, the Remote throws "No QueryClient set" errors.
Strategy B: Isolated QueryClients (Distributed Cache)
Each Remote creates its own QueryClient, ensuring true independence.
Advantages: Remotes can use different React Query versions. A crash in the Host's cache doesn't affect Remotes.
Disadvantages: Both Host and Remote fetch the same data (double network traffic). If the Remote updates user data, the Host's cache remains stale until reload.
| Feature | Shared QueryClient | Isolated QueryClient |
|---|---|---|
| Data Reuse | High (instant cache hits) | Low (relies on HTTP cache) |
| Coupling | Tight (must share React) | Loose (independent instances) |
| Invalidation | Global (one mutation updates all) | Local (manual sync required) |
| Robustness | Brittle (context issues fatal) | Robust (fail-safe) |
| Use When | Internal, trusted MFEs | 3rd-party or distinct domains |
Cross-MFE Cache Invalidation
For distributed caches requiring coordination, use BroadcastChannel:
const channel = new BroadcastChannel('query-cache-sync');
function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
channel.postMessage({ type: 'INVALIDATE', queryKey: ['products'] });
},
});
}
// Subscriber in each MFE
channel.onmessage = (event) => {
if (event.data.type === 'INVALIDATE') {
queryClient.invalidateQueries({ queryKey: event.data.queryKey });
}
};
The Emerging Paradigm: Local-First Databases
TanStack DB, currently in beta, represents a paradigm shift from "Caching API Responses" to "Syncing a Database Replica." It's particularly transformative for distributed architectures because it solves synchronization at the protocol level.
State is organized into typed Collections rather than arbitrary string keys, acting as rigid contracts. Multiple MFEs can query the same dataset with different filters without coordinating who fetches what—the database acts as the central state hub.
For multi-tab synchronization, TanStack Query's broadcastQueryClient plugin provides a bridge:
import { broadcastQueryClient } from '@tanstack/query-broadcast-client-experimental';
broadcastQueryClient({
queryClient,
broadcastChannel: 'my-app-session',
});
Event-Driven Architecture: When State Isn't the Answer
Not all communication requires shared state. For transactional "fire-and-forget" interactions, events reduce coupling:
// Shared event bus
export const authChannel = new BroadcastChannel('auth_events');
export const sendLogout = () => {
authChannel.postMessage({ type: 'LOGOUT' });
};
// For same-document communication, use CustomEvent
window.dispatchEvent(new CustomEvent('cart:open', { detail: { productId } }));
CustomEvents leverage the browser's native event loop with zero library dependencies—ideal for UI signals like a "Buy Now" button triggering a cart drawer.
Production Hardening
Three-Layer Error Handling
Module Federation 2.0's RetryPlugin handles transient network failures:
import { RetryPlugin } from '@module-federation/retry-plugin';
const mf = createInstance({
plugins: [
RetryPlugin({
retryTimes: 3,
retryDelay: 1000,
manifestDomains: ['https://cdn1.example.com', 'https://cdn2.example.com'],
}),
],
});
Combine with errorLoadRemote hooks for load fallbacks and React Error Boundaries for runtime errors.
Memory Leak Prevention
In federated architectures, MFEs mount and unmount as users navigate. Listeners registered on global stores or event buses that aren't cleaned up accumulate, causing duplicate API calls and unpredictable behavior. Strict linting enforcing cleanup functions in useEffect is mandatory.
Monorepo Structure with Turborepo
my-monorepo/
├── apps/
│ ├── host/ # Shell application
│ └── product-remote/ # Federated product module
├── packages/
│ ├── state/ # Shared stores and query hooks
│ │ ├── src/
│ │ │ ├── stores/ # Zustand stores
│ │ │ ├── hooks/ # React Query hooks
│ │ │ └── api/ # API clients
│ └── typescript-config/
├── turbo.json
└── pnpm-workspace.yaml
Prevent duplicate instances with pnpm configuration:
# .npmrc
resolve-peers-from-workspace-root=true
dedupe-peer-dependents=true
Recommendations by Team Structure
Small teams (2-5 developers): Use Zustand + TanStack Query as singletons. Build-time sharing via Turborepo internal packages. Module Federation adds unnecessary complexity.
Medium teams (5-15 developers): Hybrid approach—shared packages for core functionality (auth, session), Module Federation for feature modules. Invest in three-layer error handling.
Large organizations (multiple teams): Props-based interfaces between MFEs, centralized authentication via shell, strict version alignment. Debugging distributed state exceeds coordination overhead.
The essential rule: Never store server data in client state libraries. TanStack Query handles caching, refetching, and invalidation—duplicating in Zustand creates synchronization bugs.
Production Checklist
- [ ] Async boundary pattern (
import('./bootstrap')) in every federated module entry - [ ] Singleton configuration for React, react-dom, and all state libraries with explicit
requiredVersion - [ ] State logic extracted to shared
libs/data-accesspackages - [ ] Host exposes state instances; Remotes consume via
import from 'host/Store' - [ ] All event listeners return cleanup functions in
useEffect - [ ] BroadcastChannel-based invalidation for cross-MFE coordination
- [ ] Three-layer error handling (retry, fallback, boundary)
- [ ] pnpm/yarn resolutions enforcing identical library versions
Further Reading & Resources
Official Documentation
- Webpack Module Federation Plugin
- Module Federation Concepts
- Module Federation Shared Configuration
- TanStack Query v5 Migration Guide
Error Handling & Production Patterns
State Management Patterns
- Redux with Module Federation
- Integrating Redux with Micro-Frontends
- Scalable React State Management Comparison
- Zustand + React Query Patterns
Singleton & Context Issues
- Solving Singleton Issues in Module Federation
- Ensuring Module Uniqueness
- React Context Between Exposed Components
Top comments (0)