DEV Community

Cover image for Federated State Done Right: Zustand, TanStack Query, and the Patterns That Actually Work
martin rojas
martin rojas

Posted on • Originally published at nextsteps.dev

Federated State Done Right: Zustand, TanStack Query, and the Patterns That Actually Work

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'] 
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

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'));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 });
  }
};
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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 } }));
Enter fullscreen mode Exit fullscreen mode

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'],
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Prevent duplicate instances with pnpm configuration:

# .npmrc
resolve-peers-from-workspace-root=true
dedupe-peer-dependents=true
Enter fullscreen mode Exit fullscreen mode

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-access packages
  • [ ] 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

Error Handling & Production Patterns

State Management Patterns

Singleton & Context Issues

Advanced Topics

Top comments (0)