Oct 2026

Module Federation 2: sharing context and TanStack Query across microfrontends

When you split an app into microfrontends (MFEs), loading another team’s code is the easy part. The hard part is everyone seeing the same state: the user, the theme, the cart, and above all the server-data cache. When the cart remote adds a product, the host’s badge and the catalog’s stock need to know.

This guide solves that with the current versions (October 2026):

Package Version
@module-federation/enhanced (Webpack/Rspack) 2.9
@module-federation/vite 1.23
@tanstack/react-query 5.104
react / react-dom 19.3

The idea in one picture

host · shell creates the QueryClient remote · catalog ./ProductList remote · cart ./AddToCart · ./CartBadge share scope “default” (singletons) react · react-dom · react-dom/client @tanstack/react-query · @acme/mf-contract
One React, one TanStack Query and one contract: that is why context reaches everyone.

There are three rules; the rest of the guide applies them:

  1. One React. react, react-dom and react-dom/client as singletons in the host and in every remote.
  2. One TanStack Query. @tanstack/react-query as a singleton too. It is the module that defines QueryClientContext; if each MFE brings its own copy, each copy has its own context object.
  3. A shared contract. One package (@acme/mf-contract) holding your own contexts, the query key factories and the queryOptions. Also a singleton.

Why context gets lost

React.createContext() returns an object created when the module that defines it is evaluated. The host and a remote share a context only if they import the same instance of that module. If the remote bundles its own @tanstack/react-query, its useQueryClient() looks for a context the host never filled, and you get this even though the host renders a QueryClientProvider:

Error: No QueryClient set, use QueryClientProvider to set one

With React duplicated the symptom is Invalid hook call. With your own context package duplicated it is worse: there is no error, the remote just reads the default value.

1. The shared configuration

Define shared once, in a monorepo package, and use it in the host and in every remote. With Rspack and @module-federation/enhanced:

// packages/mf-config/shared.ts
// Each app passes its own dependencies, so requiredVersion comes from its package.json.
export const sharedFrom = (deps: Record<string, string>) => ({
  react: { singleton: true, requiredVersion: deps.react },
  "react-dom": { singleton: true, requiredVersion: deps["react-dom"] },
  // "react-dom" does not cover "react-dom/client": without this line a second ReactDOM loads.
  "react-dom/client": { singleton: true, requiredVersion: deps["react-dom"] },
  "@tanstack/react-query": { singleton: true, requiredVersion: deps["@tanstack/react-query"] },
  "@acme/mf-contract": { singleton: true, requiredVersion: deps["@acme/mf-contract"] },
});
// apps/shell/rspack.config.ts (host)
import { ModuleFederationPlugin } from "@module-federation/enhanced/rspack";
import { sharedFrom } from "@acme/mf-config/shared";
import pkg from "./package.json" with { type: "json" };

export default {
  // Replaces the old import("./bootstrap") trick.
  experiments: { asyncStartup: true },
  plugins: [
    new ModuleFederationPlugin({
      name: "shell",
      remotes: {
        catalog: "catalog@http://localhost:3001/mf-manifest.json",
        cart: "cart@http://localhost:3002/mf-manifest.json",
      },
      // Load each remote when it is used, not all of them at startup.
      shareStrategy: "loaded-first",
      shared: sharedFrom(pkg.dependencies),
    }),
  ],
};
// apps/cart/rspack.config.ts (remote, same imports as the host)
new ModuleFederationPlugin({
  name: "cart", // no hyphens: use snake_case if you need to
  exposes: {
    "./AddToCart": "./src/AddToCart.tsx",
    "./CartBadge": "./src/CartBadge.tsx",
  },
  shareStrategy: "loaded-first",
  shared: sharedFrom(pkg.dependencies),
});

With Vite

For Vite the official plugin is @module-federation/vite, part of the same Module Federation 2.0 ecosystem and recommended by Vite and VoidZero. It replaces the old @originjs/vite-plugin-federation, which doesn’t speak the same runtime and so never truly interoperated with Webpack or Rspack remotes. The official plugin ships a migration guide from OriginJS.

One package is enough. The plugin already brings @module-federation/runtime and @module-federation/sdk, so you only need @module-federation/enhanced if some app is built with Webpack or Rspack.

npm install -D @module-federation/vite

The shared block is exactly the same sharedFrom. Only the wrapper changes:

// apps/cart/vite.config.ts (remote)
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { federation } from "@module-federation/vite";
import { sharedFrom } from "@acme/mf-config/shared";
import pkg from "./package.json" with { type: "json" };

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: "cart",
      filename: "remoteEntry.js",
      manifest: true, // emits mf-manifest.json
      exposes: {
        "./AddToCart": "./src/AddToCart.tsx",
        "./CartBadge": "./src/CartBadge.tsx",
      },
      shareStrategy: "loaded-first",
      shared: sharedFrom(pkg.dependencies), // React 19, taken from package.json
    }),
  ],
  // In dev the host requests the remote's assets: they need an absolute URL.
  server: { port: 3002, origin: "http://localhost:3002" },
});
// apps/shell/vite.config.ts (host)
federation({
  name: "shell",
  remotes: {
    cart: {
      type: "module", // without it you get "var" and a warning: Vite remotes are ESM
      name: "cart",
      entry: "http://localhost:3002/remoteEntry.js",
    },
  },
  shareStrategy: "loaded-first",
  shared: sharedFrom(pkg.dependencies),
});

With Vite 8 you don’t need to touch build.target: the default target already supports the top-level await the runtime uses. Rsbuild works the same way, with pluginModuleFederation(config) from @module-federation/rsbuild-plugin.

Three details that go wrong again and again:

  • experiments.asyncStartup: true. Without it, or without the old async bootstrap, you get RUNTIME-006 or the classic Shared module is not available for eager consumption. Don’t fix it with eager: true everywhere: it puts the dependencies in the entry and you lose sharing.
  • shareStrategy: "loaded-first". The default, version-first, downloads every remote at startup to pick the highest version. If one is down, the host fails on boot.
  • exposes keys start with ./, and name has no hyphens.

2. The contract package

The contract is the only place keys live. That way nobody writes ['product', 1] in one MFE and ['products', 'detail', '1'] in another, which is the number one cause of invalidations that do nothing.

// packages/mf-contract/src/queries.ts
import { queryOptions } from "@tanstack/react-query";

export type Product = { id: string; name: string; stock: number };

// The first segment is the DOMAIN, not the MFE name.
export const productKeys = {
  all: ["products"] as const,
  lists: () => [...productKeys.all, "list"] as const,
  list: (filters: { category?: string; page: number }) => [...productKeys.lists(), filters] as const,
  details: () => [...productKeys.all, "detail"] as const,
  detail: (id: string) => [...productKeys.details(), id] as const,
};

export const cartKeys = {
  all: ["cart"] as const,
  current: () => [...cartKeys.all, "current"] as const,
};

export const productQueries = {
  detail: (id: string) =>
    queryOptions({
      queryKey: productKeys.detail(id),
      queryFn: ({ signal }) => fetch(`/api/products/${id}`, { signal }).then((r) => r.json() as Promise<Product>),
      staleTime: 60_000,
    }),
};

Two conventions worth writing down in the contract’s README:

  • Keys by domain (products, cart), never by team (catalog). Any MFE can then invalidate ['cart'] without knowing who renders the cart.
  • Stable types. ['products', 'detail', 1] and ['products', 'detail', '1'] are different keys. The factory prevents that.

The contract is also where your own contexts go:

// packages/mf-contract/src/session.tsx
import { createContext, useContext } from "react";

export type Session = { userId: string; locale: string } | null;

const SessionContext = createContext<Session>(null);
export const SessionProvider = SessionContext.Provider;
export const useSession = () => useContext(SessionContext);

Because the package is a singleton, SessionContext is the same object in every MFE.

3. The host creates the only QueryClient

// apps/shell/src/App.tsx
import { lazy, Suspense } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { SessionProvider, type Session } from "@acme/mf-contract";

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 30_000 } },
});

const ProductList = lazy(() => import("catalog/ProductList"));
const CartBadge = lazy(() => import("cart/CartBadge"));

export function App({ session }: { session: Session }) {
  return (
    <QueryClientProvider client={queryClient}>
      <SessionProvider value={session}>
        <Suspense fallback={null}>
          <CartBadge />
          <ProductList />
        </Suspense>
      </SessionProvider>
      {/* One devtools: it sees every MFE's keys. */}
      <ReactQueryDevtools />
    </QueryClientProvider>
  );
}

Remotes don’t create providers in what they expose. They only create them in their own entry point, for standalone development:

// apps/catalog/src/ProductList.tsx (exposed)
import { useQuery } from "@tanstack/react-query";
import { productQueries, useSession } from "@acme/mf-contract";

export default function ProductList() {
  const session = useSession();
  const { data } = useQuery(productQueries.detail("42"));
  return <p>{data?.name} · {data?.stock} in stock · {session?.locale}</p>;
}
// apps/catalog/src/main.tsx (only when catalog runs on its own)
const devClient = new QueryClient();
createRoot(root).render(
  <QueryClientProvider client={devClient}>
    <ProductList />
  </QueryClientProvider>,
);

4. Invalidating from one MFE to another

remote · cart useMutation → onSuccess TanStack Query cache (one) invalidateQueries({ queryKey: cartKeys.all }) host · shell cart badge remote · catalog product stock refetch refetch ['cart', …] ['products', 'detail', id]
A mutation in one remote invalidates by domain prefix, and both the host and the other remote refetch.
// apps/cart/src/AddToCart.tsx (remote)
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { cartKeys, productKeys } from "@acme/mf-contract";

export default function AddToCart({ id }: { id: string }) {
  const queryClient = useQueryClient(); // the host's

  const add = useMutation({
    mutationFn: (productId: string) =>
      fetch("/api/cart", { method: "POST", body: JSON.stringify({ productId }) }),
    // Returning the promise keeps the mutation pending until the refetch finishes.
    onSuccess: () =>
      Promise.all([
        queryClient.invalidateQueries({ queryKey: cartKeys.all }), // host badge
        queryClient.invalidateQueries({ queryKey: productKeys.detail(id) }), // stock in catalog
      ]),
  });

  return <button onClick={() => add.mutate(id)}>Add</button>;
}

invalidateQueries matches by prefix: ['cart'] invalidates ['cart', 'current'] and every other cart key. Use exact: true or predicate when you need to narrow it.

5. When you can’t share the client

Some remotes are out of your hands: another team on a different release cadence, a third party, or a remote mounted with Module Federation’s React Bridge (createBridgeComponent / createRemoteAppComponent). The bridge mounts the remote in its own React root, so the host’s providers don’t reach it, the QueryClientProvider included.

In those cases:

  • Each remote has its own QueryClient. In v5 you can pass it explicitly: useQuery(options, queryClient). The v4 context prop is gone.
  • Sync invalidations with an event that carries the key, for example { type: "invalidate", queryKey: ["cart"] } over a BroadcastChannel or a host event bus. Each client listens and calls invalidateQueries with that key. Keys still come from the contract.
  • Accept the cost: duplicate requests and one cache per remote.

Different versions

  • With singleton, if host and remote ask for different versions, the higher one loads and the other side logs a warning. Within TanStack Query v5 that is usually safe.
  • A major jump (a remote on v4, the host on v5) breaks at runtime: the API changed (useQuery(key, fn) no longer exists). Upgrade majors together, or isolate that remote with its own client or its own shareScope.
  • Don’t set requiredVersion: false on packages that define context: you hide the problem until production.

Common mistakes

  1. react-dom/client missing from shared: a second ReactDOM and hydration errors.
  2. @tanstack/react-query shared only in the host: No QueryClient set in the remote.
  3. Contract or context package without singleton: the remote reads the default value, silently.
  4. eager: true everywhere instead of asyncStartup.
  5. Keys typed by hand in each MFE, or grouped by team: invalidations miss.
  6. One ReactQueryDevtools per remote. Mount one, in the host.
  7. Different QueryClient defaults per team. The host sets them; remotes adjust per query with queryOptions.
  8. With pnpm, the same library resolved from different paths: enable allowNodeModulesSuffixMatch or a bundler alias.
  9. In SSR, a module-level QueryClient is shared across requests and leaks data. Create one per request.
  10. A remote that mutates data without invalidating the keys others depend on. Document in the contract which domain each mutation touches.

Sources