Federated State Done Right: Zustand, TanStack Query, and the Patterns That Actually Work
Source: Dev.to
The Problem
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 It Happens
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.
Root cause: 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,
- cart items that vanish when navigating between micro‑frontends.
How Webpack Shares Modules
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.
- Host bootstraps – it initializes entries in
__webpack_share_scopes__.defaultfor every library marked as shared. Each entry contains the version number and a factory function to load the module. - Remote bootstraps – 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 the handshake fails, the Remote loads its own copy of React, creating a parallel universe where the Host’s providers don’t exist.
Required Webpack Configuration
// webpack.config.js – Every federated module needs this
const deps = require('./package.json').dependencies;
module.exports = {
// …other config
plugins: [
new ModuleFederationPlugin({
// …name, remotes, exposes, etc.
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 properties matter most
| Property | What it does |
|---|---|
singleton: true | Guarantees only one instance exists across all federated modules. |
strictVersion | Throws an error when versions conflict instead of silently loading dupes. |
requiredVersion | Enforces a semver range, preventing accidental mismatches. |
Loading versions dynamically from package.json keeps the config in sync with the installed packages.
Dealing with “Shared module is not available for eager consumption”
The error often appears in 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.
Two possible solutions
-
Eager loading (quick fix, adds bundle size)
// In the shared config react: { singleton: true, eager: true, requiredVersion: deps.react },This forces React into the initial bundle, adding ~100‑150 KB gzipped to Time‑to‑Interactive.
-
Asynchronous entry point (recommended)
// 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 timebootstrap.jsruns, React is available in the shared scope.
Why Zustand Works Well in Micro‑Frontends
- Tiny bundle size – ~1 KB gzipped.
- Singleton‑friendly architecture – no provider hierarchy required.
- No need for React Context – stores are plain JavaScript objects.
Common bug: Remote doesn’t react to Host updates
The Remote renders the initial state correctly, but when the Host updates state, the Remote stays “stuck” on the initial value.
What’s happening?
Both Host and Remote import a store via build‑time aliases, so the bundler includes the store code in both bundles. At runtime:
- Host creates
Store_Instance_A. - Remote creates
Store_Instance_B.
The Host updates Instance A; the Remote listens to Instance B. No update propagates.
Fix: Share the exact same JavaScript object
// Remote module exposes its store (e.g., src/store.js)
import create from 'zustand';
export const useSharedStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// Host consumes the remote store
import { useSharedStore } from 'remoteApp/store';
function Counter() {
const { count, increment } = useSharedStore();
return (
<div>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}
Because the store module is shared (via the shared config above), both Host and Remote receive the same useSharedStore reference, guaranteeing that state updates are observed everywhere.
TL;DR
- Configure shared libraries as singletons (
singleton: true). - Enable strict version checking (
strictVersion: true). - Load the entry point asynchronously to give the share scope time to initialise.
- Prefer Zustand for client‑side state in federated apps – it works without providers.
- Expose the same store instance from a shared module so Host and Remote truly share state.
With these patterns in place, your federated micro‑frontends will behave as a single, cohesive application rather than a collection of isolated islands.
Module Federation – Store Sharing Example
1. Expose the Store from the Host
// cart-remote/webpack.config.js
module.exports = {
// …
exposes: {
'./CartStore': './src/stores/cartStore',
'./CartComponent': './src/components/Cart',
},
};
2. Host Store Implementation (Zustand)
// 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: [] as any[],
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);
3. Remote Consumes the Host Store
// 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>;
};
Note: When
remote/App.tsximportshost/CartStore, Webpack delegates the request to the Module Federation runtime. The runtime returns the exact same store instance that the Host already created, so both sides share the same closure and state.
Redux‑Based Architecture – Avoid Nested Providers
Problem
Wrapping each Remote in its own <Provider> creates dangerous nested providers.
Cleaner Pattern – Dependency Injection
// Remote component contract
interface Props {
store: StoreType;
}
const RemoteWidget = ({ store }: Props) => {
const state = useStore(store);
return <div>{state.value}</div>;
};
export default RemoteWidget;
// Host side
const RemoteWidget = React.lazy(() => import('remote/Widget'));
const App = () => (
<React.Suspense fallback="Loading…">
<RemoteWidget store={hostStore} />
</React.Suspense>
);
- Decouples the Remote from the store location.
- Enables testing with mock stores.
- Keeps ownership of the store firmly with the Host.
TanStack Query v5 – Sharing a QueryClient
5.1 Shared QueryClient (recommended for internal MFEs)
// host/src/queryClient.ts (exposed via Module Federation)
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 300_000,
refetchOnWindowFocus: false,
},
},
});
// Remote side
const { queryClient } = await import('host/QueryClient');
function RemoteApp() {
return (
<QueryClientProvider client={queryClient}>
{/* Remote components */}
</QueryClientProvider>
);
}
Advantages
| ✅ | Benefit |
|---|---|
| ✅ | Instant cache deduplication – the Remote gets data already fetched by the Host without a network request. |
| ✅ | Global invalidation – a mutation in any module updates all consumers. |
Risk
- Requires the same React instance across Host and Remote. If they differ, you’ll see “No QueryClient set” errors.
5.2 Isolated QueryClient (useful for third‑party MFEs)
// Remote creates its own client
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient(/* …options… */);
function RemoteApp() {
return (
<QueryClientProvider client={queryClient}>
{/* Remote components */}
</QueryClientProvider>
);
}
Advantages
| ✅ | Benefit |
|---|---|
| ✅ | Remotes can run different versions of React Query. |
| ✅ | A crash in the Host’s cache does not affect the Remote. |
Disadvantages
| ❌ | Drawback |
|---|---|
| ❌ | Duplicate network traffic – both Host and Remote may fetch the same data. |
| ❌ | Mutations in the Remote do not automatically update the Host’s cache (manual sync required). |
Comparison: Shared vs. Isolated QueryClient
| 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) |
| When to Use | Internal, trusted MFEs | 3rd‑party or distinct domains |
Distributed Cache Coordination – BroadcastChannel
// Broadcast channel for cache invalidation
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 });
}
};
TanStack DB (Beta) – “Sync a Database Replica”
- Concept: Instead of caching API responses, TanStack DB syncs a local replica of a remote database.
- Data Model: Typed Collections act as rigid contracts; MFEs can query the same collection with different filters without coordinating fetches.
- Benefit: The replica becomes the single source of truth for all MFEs.
Multi‑Tab Synchronization – broadcastQueryClient Plugin
import { broadcastQueryClient } from '@tanstack/query-broadcast-client-experimental';
broadcastQueryClient({
queryClient,
broadcastChannel: 'my-app-session',
});
Keeps all tabs of the same app in sync by broadcasting cache updates.
Event‑Based Communication (Fire‑and‑Forget)
10.1 Shared Event Bus (BroadcastChannel)
// authChannel.ts
export const authChannel = new BroadcastChannel('auth_events');
export const sendLogout = () => {
authChannel.postMessage({ type: 'LOGOUT' });
};
10.2 Same‑Document Signals (CustomEvent)
// UI signal – open cart drawer
window.dispatchEvent(
new CustomEvent('cart:open', { detail: { productId: 123 } })
);
- Pros: Zero library dependencies, leverages the browser’s native event loop.
- Use Cases: UI‑only interactions like “Buy Now” → open cart drawer.
Module Federation 2.0 – RetryPlugin
import { RetryPlugin } from '...'; // add appropriate import path
// Configuration example