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
Hay tres reglas y el resto de la guía es su aplicación:
- Un solo React.
react,react-domyreact-dom/clientcomo singleton en el host y en todos los remotos. - Un solo TanStack Query.
@tanstack/react-querytambién como singleton. Es el módulo que defineQueryClientContext; si cada MFE trae su copia, cada una tiene su propio objeto de contexto. - Un contrato compartido. Un paquete (
@acme/mf-contract) con los contextos propios, las query key factories y losqueryOptions. 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 antiguobootstrapasíncrono, apareceRUNTIME-006o el clásico Shared module is not available for eager consumption. No lo arregles coneager: trueen 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
exposesempiezan por./y elnameno 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
// 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 propcontextde la v4 ya no existe. - Sincroniza invalidaciones con un evento que lleve la clave, por ejemplo
{ type: "invalidate", queryKey: ["cart"] }sobre unBroadcastChannelo un bus del host. Cada cliente escucha y llama ainvalidateQueriescon 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 propioshareScope. - No pongas
requiredVersion: falseen paquetes que definen contexto: escondes el problema hasta producción.
Errores frecuentes
react-dom/clientfuera delshared: segundo ReactDOM y errores de hidratación.@tanstack/react-querycompartido solo en el host: No QueryClient set en el remoto.- Contrato o paquete de contexto sin singleton: el remoto lee el valor por defecto, sin errores.
eager: truepor todas partes en vez deasyncStartup.- Claves escritas a mano en cada MFE o agrupadas por equipo: las invalidaciones no aciertan.
- Un
ReactQueryDevtoolspor remoto. Va uno, en el host. - Defaults distintos de
QueryClientsegún el equipo. Los pone el host; los remotos ajustan por query conqueryOptions. - Con pnpm, la misma librería resuelta desde rutas distintas: activa
allowNodeModulesSuffixMatcho unaliasdel bundler. - En SSR, un
QueryClienta nivel de módulo se comparte entre peticiones y filtra datos. Crea uno por petición. - 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
- Documentación de Module Federation: shared, shareStrategy, runtime API
- Migración a TanStack Query v5
- TkDodo, Effective React Query Keys