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

Published: (December 16, 2025 at 03:53 PM EST)
9 min read
Source: Dev.to

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.

  1. Host bootstraps – it initializes entries in __webpack_share_scopes__.default for every library marked as shared. Each entry contains the version number and a factory function to load the module.
  2. 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

PropertyWhat it does
singleton: trueGuarantees only one instance exists across all federated modules.
strictVersionThrows an error when versions conflict instead of silently loading dupes.
requiredVersionEnforces 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

  1. 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.

  2. 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 time bootstrap.js runs, 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.tsx imports host/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

// 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

FeatureShared QueryClientIsolated QueryClient
Data ReuseHigh (instant cache hits)Low (relies on HTTP cache)
CouplingTight (must share React)Loose (independent instances)
InvalidationGlobal (one mutation updates all)Local (manual sync required)
RobustnessBrittle (context issues fatal)Robust (fail‑safe)
When to UseInternal, trusted MFEs3rd‑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
Back to Blog

Related posts

Read more »

Chasing 240 FPS in LLM Chat UIs

TL;DR I built a benchmark suite to test various optimizations for streaming LLM responses in a React UI. Key takeaways: 1. Build a proper state first, then opt...