Oct 2026

Module Federation 2: compartir contexto y TanStack Query entre microfrontends

Cuando partes una aplicación en microfrontends (MFE), lo difícil no es cargar el código de otro equipo. Lo difícil es que todos vean el mismo estado: el usuario, el tema, el carrito, y sobre todo la caché de datos del servidor. Si el remoto del carrito añade un producto, el badge del host y el stock del catálogo tienen que enterarse.

Esta guía resuelve eso con las versiones actuales (octubre de 2026):

Paquete Versión
@module-federation/enhanced (Webpack/Rspack) 2.9
@module-federation/vite 1.23
@tanstack/react-query 5.104
react / react-dom 19.3

La idea en un dibujo

host · shell crea el QueryClient remoto · catalog ./ProductList remoto · cart ./AddToCart · ./CartBadge share scope «default» (singletons) react · react-dom · react-dom/client @tanstack/react-query · @acme/mf-contract
Un solo React, un solo TanStack Query y un solo contrato: por eso el contexto llega a todos.

Hay tres reglas y el resto de la guía es su aplicación:

  1. Un solo React. react, react-dom y react-dom/client como singleton en el host y en todos los remotos.
  2. Un solo TanStack Query. @tanstack/react-query también como singleton. Es el módulo que define QueryClientContext; si cada MFE trae su copia, cada una tiene su propio objeto de contexto.
  3. Un contrato compartido. Un paquete (@acme/mf-contract) con los contextos propios, las query key factories y los queryOptions. También singleton.

Por qué el contexto se pierde

React.createContext() devuelve un objeto que se crea al evaluar el módulo que lo define. El host y un remoto comparten contexto solo si importan la misma instancia de ese módulo. Si el remoto empaqueta su propio @tanstack/react-query, su useQueryClient() busca un contexto que el host nunca ha rellenado, y verás esto aunque el host tenga su QueryClientProvider:

Error: No QueryClient set, use QueryClientProvider to set one

Con React duplicado el síntoma es Invalid hook call. Con un paquete de contexto propio duplicado es peor: no hay error, el remoto simplemente lee el valor por defecto.

1. La configuración compartida

Define el shared una vez, en un paquete del monorepo, y úsalo en el host y en cada remoto. Con Rspack y @module-federation/enhanced:

// packages/mf-config/shared.ts
// Cada app le pasa sus dependencias, así requiredVersion sale de su 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" no cubre "react-dom/client": sin esta línea entra un segundo ReactDOM.
  "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 {
  // Sustituye al viejo truco de import("./bootstrap").
  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",
      },
      // Carga cada remoto cuando se usa, no todos al arrancar.
      shareStrategy: "loaded-first",
      shared: sharedFrom(pkg.dependencies),
    }),
  ],
};
// apps/cart/rspack.config.ts (remoto, mismos imports que el host)
new ModuleFederationPlugin({
  name: "cart", // sin guiones: usa snake_case si hace falta
  exposes: {
    "./AddToCart": "./src/AddToCart.tsx",
    "./CartBadge": "./src/CartBadge.tsx",
  },
  shareStrategy: "loaded-first",
  shared: sharedFrom(pkg.dependencies),
});

Con Vite

En Vite el plugin oficial es @module-federation/vite, del mismo ecosistema Module Federation 2.0 y recomendado por Vite y VoidZero. Sustituye al antiguo @originjs/vite-plugin-federation, que no habla el mismo runtime y por eso no interopera de verdad con remotos de Webpack o Rspack. El plugin oficial tiene una guía de migración desde OriginJS.

Basta con un paquete. El plugin ya trae @module-federation/runtime y @module-federation/sdk, así que @module-federation/enhanced solo hace falta si alguna app se construye con Webpack o Rspack.

npm install -D @module-federation/vite

El shared es exactamente el mismo sharedFrom. Solo cambia el envoltorio:

// apps/cart/vite.config.ts (remoto)
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, // genera mf-manifest.json
      exposes: {
        "./AddToCart": "./src/AddToCart.tsx",
        "./CartBadge": "./src/CartBadge.tsx",
      },
      shareStrategy: "loaded-first",
      shared: sharedFrom(pkg.dependencies), // React 19, sacado del package.json
    }),
  ],
  // En desarrollo los assets del remoto se piden desde el host: necesitan URL absoluta.
  server: { port: 3002, origin: "http://localhost:3002" },
});
// apps/shell/vite.config.ts (host)
federation({
  name: "shell",
  remotes: {
    cart: {
      type: "module", // sin él toma "var" y avisa: los remotos de Vite son ESM
      name: "cart",
      entry: "http://localhost:3002/remoteEntry.js",
    },
  },
  shareStrategy: "loaded-first",
  shared: sharedFrom(pkg.dependencies),
});

Con Vite 8 no hace falta tocar build.target: el objetivo por defecto ya admite el await de nivel superior que usa el runtime. Con Rsbuild es igual, con pluginModuleFederation(config) de @module-federation/rsbuild-plugin.

Tres detalles que fallan una y otra vez:

  • experiments.asyncStartup: true. Sin él, o sin el antiguo bootstrap asíncrono, aparece RUNTIME-006 o el clásico Shared module is not available for eager consumption. No lo arregles con eager: true en todo: metes las dependencias en el entry y pierdes el reparto.
  • shareStrategy: "loaded-first". El valor por defecto, version-first, descarga todos los remotos al arrancar para elegir la versión más alta. Si uno está caído, el host falla al inicio.
  • Los exposes empiezan por ./ y el name no lleva guiones.

2. El paquete contrato

El contrato es el único sitio donde viven las claves. Así nadie escribe ['product', 1] en un MFE y ['products', 'detail', '1'] en otro, que es la causa número uno de invalidaciones que no hacen nada.

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

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

// El primer segmento es el DOMINIO, no el nombre del MFE.
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,
    }),
};

Dos convenciones que conviene escribir en el README del contrato:

  • Claves por dominio (products, cart), nunca por equipo (catalog). Así cualquier MFE puede invalidar ['cart'] sin saber quién pinta el carrito.
  • Tipos estables. ['products', 'detail', 1] y ['products', 'detail', '1'] son claves distintas. La factory lo impide.

El contrato también es el lugar para tus contextos propios:

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

Como el paquete es singleton, SessionContext es el mismo objeto en todos los MFE.

3. El host crea el único 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>
      {/* Un único devtools: ve las claves de todos los MFE. */}
      <ReactQueryDevtools />
    </QueryClientProvider>
  );
}

Los remotos no crean proveedores en lo que exponen. Solo los crean en su propio arranque para el desarrollo en solitario:

// apps/catalog/src/ProductList.tsx (expuesto)
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} uds · {session?.locale}</p>;
}
// apps/catalog/src/main.tsx (solo cuando catalog corre solo)
const devClient = new QueryClient();
createRoot(root).render(
  <QueryClientProvider client={devClient}>
    <ProductList />
  </QueryClientProvider>,
);

4. Invalidar de un MFE a otro

remoto · cart useMutation → onSuccess caché de TanStack Query (una) invalidateQueries({ queryKey: cartKeys.all }) host · shell badge del carrito remoto · catalog stock del producto refetch refetch ['cart', …] ['products', 'detail', id]
Una mutación en un remoto invalida por prefijo de dominio y refrescan el host y el otro remoto.
// apps/cart/src/AddToCart.tsx (remoto)
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { cartKeys, productKeys } from "@acme/mf-contract";

export default function AddToCart({ id }: { id: string }) {
  const queryClient = useQueryClient(); // el del host

  const add = useMutation({
    mutationFn: (productId: string) =>
      fetch("/api/cart", { method: "POST", body: JSON.stringify({ productId }) }),
    // Devolver la promesa mantiene la mutación pendiente hasta que termina el refetch.
    onSuccess: () =>
      Promise.all([
        queryClient.invalidateQueries({ queryKey: cartKeys.all }), // badge del host
        queryClient.invalidateQueries({ queryKey: productKeys.detail(id) }), // stock en catalog
      ]),
  });

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

invalidateQueries compara por prefijo: ['cart'] invalida ['cart', 'current'] y cualquier otra clave del carrito. Usa exact: true o predicate cuando quieras afinar.

5. Cuando no puedes compartir el cliente

Hay remotos que no controlas: otro equipo con otro ritmo de versiones, un tercero, o un remoto montado con el React Bridge de Module Federation (createBridgeComponent / createRemoteAppComponent). El bridge monta el remoto en su propia raíz de React, así que los proveedores del host no le llegan, tampoco el QueryClientProvider.

En esos casos:

  • Cada remoto tiene su propio QueryClient. En la v5 puedes pasarlo explícitamente: useQuery(options, queryClient). La prop context de la v4 ya no existe.
  • Sincroniza invalidaciones con un evento que lleve la clave, por ejemplo { type: "invalidate", queryKey: ["cart"] } sobre un BroadcastChannel o un bus del host. Cada cliente escucha y llama a invalidateQueries con esa clave. Las claves siguen saliendo del contrato.
  • Asume el coste: peticiones duplicadas y una caché por remoto.

Versiones distintas

  • Con singleton, si host y remoto piden versiones distintas, se carga la más alta y el otro lado avisa en consola. Dentro de la v5 de TanStack Query suele ser seguro.
  • Un salto de versión mayor (un remoto en v4 y el host en v5) rompe en ejecución: la API cambió (useQuery(key, fn) ya no existe). Sube las versiones mayores a la vez, o aísla ese remoto con su propio cliente o su propio shareScope.
  • No pongas requiredVersion: false en paquetes que definen contexto: escondes el problema hasta producción.

Errores frecuentes

  1. react-dom/client fuera del shared: segundo ReactDOM y errores de hidratación.
  2. @tanstack/react-query compartido solo en el host: No QueryClient set en el remoto.
  3. Contrato o paquete de contexto sin singleton: el remoto lee el valor por defecto, sin errores.
  4. eager: true por todas partes en vez de asyncStartup.
  5. Claves escritas a mano en cada MFE o agrupadas por equipo: las invalidaciones no aciertan.
  6. Un ReactQueryDevtools por remoto. Va uno, en el host.
  7. Defaults distintos de QueryClient según el equipo. Los pone el host; los remotos ajustan por query con queryOptions.
  8. Con pnpm, la misma librería resuelta desde rutas distintas: activa allowNodeModulesSuffixMatch o un alias del bundler.
  9. En SSR, un QueryClient a nivel de módulo se comparte entre peticiones y filtra datos. Crea uno por petición.
  10. Un remoto que muta datos sin invalidar las claves de las que dependen otros. Documenta en el contrato qué dominio afecta cada mutación.

Fuentes