Una app host con un único store compartido que un remote federado llena con datos en vivo de una API

Un store compartido: estado de servidor entre remotes federados en React Native

El post anterior terminó con una promesa: las pestañas dejan de tener datos hardcodeados y comparten un store entre los remotes, con datos reales de una API. Este post la cumple.

Se apoya en estado de servidor y estado de cliente, del breve desvío de la serie. La división entre los datos de los que es dueño un servidor y los datos de los que es dueña la app se da por sabida aquí, no se vuelve a explicar. Este post trata de una de sus mitades, el estado de servidor, bajo federación: un store de Redux Toolkit (RTK) en el host, una caché de RTK Query que los remotes comparten, y datos en vivo de PokéAPI que reemplazan los cinco nombres que la lista lleva arrastrando desde el post 2.

La forma que vamos a construir, antes de cualquier código:

host la cáscarainjectEndpointsuna instanciafetch, caché, dedupuseGetPokemonListQuerystore de Redux+ caché de RTK Query@pokedex/contractsel baseApi compartidoremote listinyecta getPokemonListPokéAPI

Lo único que hay que tener en la cabeza: baseApi es un único objeto, y cada lado importa exactamente el mismo. Eso es lo que hace que la caché sea compartida. Rómpelo, y la app se rompe de una forma que merece la pena ver, así que la romperemos a propósito cerca del final.

Sigue con tu propio código del post 5 si lo construiste paso a paso. Si no, parte de su estado final:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-05-contracts

El paquete de contratos gana una costura en runtime

Hasta ahora @pokedex/contracts solo ha contenido tipos. Cada export se borraba al construir, así que nada de él llegaba a un bundle. Ahora gana su primer export de runtime: el objeto API de RTK Query a través del cual toda la app hace sus peticiones.

Vive aquí, en el paquete compartido, y no en el host, por una razón. Un remote federado solo puede añadir sus endpoints a la misma instancia de baseApi que el store del host conectó. Como @pokedex/contracts es un singleton de Module Federation, el host y cada remote importan este mismo objeto exacto. Así que el baseApi.injectEndpoints({...}) de un remote se registra contra la única caché y el único middleware que el store ya ejecuta. Una instancia significa una caché HTTP, un pipeline de deduplicación, un grafo de tags a través de cada remote, incluidos los remotes desplegados mucho después de la cáscara. packages/contracts/src/api.ts:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { z } from 'zod';

export const baseApi = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
  tagTypes: ['PokemonList'],
  endpoints: () => ({}),
});

export interface PokemonSummary {
  id: number;
  name: string;
  spriteUri: string;
}

const PokemonListResponseSchema = z.object({
  results: z.array(z.object({ name: z.string(), url: z.string() })),
});

export function artworkUri(id: number): string {
  return `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`;
}

export function idFromResourceUrl(url: string): number {
  const match = url.match(/\/(\d+)\/?$/);
  return match ? Number(match[1]) : 0;
}

function formatName(name: string): string {
  return name
    .split('-')
    .map(word => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');
}

export function parsePokemonList(raw: unknown): PokemonSummary[] {
  const { results } = PokemonListResponseSchema.parse(raw);
  return results.map(entry => {
    const id = idFromResourceUrl(entry.url);
    return { id, name: formatName(entry.name), spriteUri: artworkUri(id) };
  });
}

createApi sin endpoints construye una cáscara vacía: un reducer, algo de middleware, y un método injectEndpoints que los remotes llamarán. fetchBaseQuery es un pequeño wrapper sobre fetch que antepone la URL base y parsea el JSON. tagTypes nombra la única etiqueta por la que esta app invalida; de momento no hace nada, y hace trabajo de verdad en la última sección.

parsePokemonList es la parte que señalaba el post 5. El contrato guarda el tipo en el que los dos lados están de acuerdo, pero un tipo es una promesa en tiempo de compilación, y ya no está cuando de verdad aterriza una respuesta. Un campo renombrado o un null donde antes había un string se cuela directo por un cast escrito a mano y peta tres pantallas más allá. Así que la respuesta en crudo se valida con un esquema de Zod justo en la costura, y una forma incorrecta se convierte en un valor que podemos manejar en lugar de en un crash. La validación en runtime tiene su propio post más adelante; aquí es el guardián de la frontera a través del cual se llena la caché compartida.

Añadir un export de runtime y dos dependencias peer es un cambio incompatible, así que la versión sube a 2.0.0. packages/contracts/package.json:

{
  "name": "@pokedex/contracts",
  "version": "2.0.0",
  "dependencies": {
    "zod": "^3.25.76"
  },
  "peerDependencies": {
    "@reduxjs/toolkit": ">=2.10.0",
    "react": "*",
    "react-redux": ">=9"
  }
}

zod es una dependencia de runtime de verdad, así que viaja dentro del paquete. @reduxjs/toolkit y react-redux son peers: el host las instala, y el contrato toma prestadas las copias del host en lugar de empaquetar las suyas. Antes, un paso de limpieza: si ejecutaste la demo de cambio incompatible del post 5, tu registro ya tiene la 2.0.0 de ids como string, y Verdaccio se niega a publicar sobre una versión existente. Elimínala:

npm unpublish @pokedex/contracts@2.0.0 --registry http://localhost:4873

Después instala las dependencias de desarrollo del paquete y publica el nuevo major de verdad:

cd packages/contracts
npm install
npm publish
+ @pokedex/contracts@2.0.0

El post 5 mostró el caret rechazando un major por su cuenta. Sigue haciéndolo. El host y el list están en ^1.1.0, así que npm install los deja en 1.1.0 incluso ahora que existe 2.0.0. Adoptar el nuevo contrato es un movimiento deliberado, un rango cada vez, que es lo siguiente que hacemos.

Un store en el host

El host gana un store. apps/host/src/store.ts:

import { combineSlices, configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@pokedex/contracts';

const rootReducer = combineSlices(baseApi);
export type RootState = ReturnType<typeof rootReducer>;

export const store = configureStore({
  reducer: rootReducer,
  middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
});

export type AppDispatch = typeof store.dispatch;
export { rootReducer };

combineSlices es el root reducer de RTK 2.x que puede aceptar más slices en runtime. Un remote llamará a rootReducer.inject(...) para añadir sus propios reducers más adelante; eso es estado de cliente, y el próximo post. Por ahora el único slice es el de baseApi: la caché compartida de estado de servidor.

baseApi.middleware es estructural. Ejecuta el ciclo de vida de la caché: peticiones, deduplicación, invalidación de tags, desalojo de caché. Déjalo fuera del store y la primera query lanza un red box en desarrollo, con RTK nombrando el error sin rodeos:

Warning: Middleware for RTK-Query API at reducerPath "api" has not been added to the store.
You must add the middleware for RTK-Query to function correctly!

Un fallo ruidoso, por tanto. Tenlo presente, porque el sabotaje del final de este post no recibe ningún aviso.

El store se pone por encima de todo el árbol, para que cada remote federado en él pueda leer la caché. apps/host/App.tsx, el wrapper:

import { Provider } from 'react-redux';
import { store } from './src/store';

export default function App() {
  return (
    <Provider store={store}>
      <SafeAreaProvider>
        <NavigationContainer>
          {/* the tab navigator from post 4 */}
        </NavigationContainer>
      </SafeAreaProvider>
    </Provider>
  );
}

Los remotes nunca crean ni importan un store. Se renderizan dentro de este árbol de React y llegan a él a través del singleton compartido de react-redux, igual que llegaban al contexto compartido de safe-area allá en el post 3.

Comparte el trío de estado

Para que un remote inyecte en el baseApi del host, tres paquetes tienen que resolverse a una sola copia en runtime: @reduxjs/toolkit, react-redux, y el propio @pokedex/contracts. Añádelos a la config compartida del host como singletons eager. apps/host/rspack.config.mjs, lo que se añade a shared:

'@reduxjs/toolkit': {
  singleton: true,
  eager: true,
  requiredVersion: pkg.dependencies['@reduxjs/toolkit'],
},
'react-redux': {
  singleton: true,
  eager: true,
  requiredVersion: pkg.dependencies['react-redux'],
},
'@pokedex/contracts': {
  singleton: true,
  eager: true,
  requiredVersion: pkg.dependencies['@pokedex/contracts'],
},

El remote list declara los mismos tres, singleton: true pero no eager: consume las copias del host desde el share scope en lugar de proveer las suyas. Esta es la primera vez que @pokedex/contracts aparece en un mapa shared. Hasta ahora era solo tipos, borrado al construir, así que no había nada que compartir. Ahora lleva baseApi, y una sola instancia es justo el sentido de todo esto.

Las dos apps además añaden los paquetes a sus dependencias y suben el contrato:

( cd apps/host && npm install @reduxjs/toolkit@^2.12.0 react-redux@^9.3.0 @pokedex/contracts@^2.0.0 )
( cd apps/list && npm install @reduxjs/toolkit@^2.12.0 react-redux@^9.3.0 @pokedex/contracts@^2.0.0 )

El remote profile no recibe nada. Sigue renderizando su tarjeta de entrenador estática, y nunca toca el store. Los remotes optan por el estado compartido; la cáscara no se lo impone.

El remote list: datos reales

Ahora el remote inyecta su endpoint. apps/list/src/listApi.ts:

import { baseApi, parsePokemonList, type PokemonSummary } from '@pokedex/contracts';

const listApi = baseApi.injectEndpoints({
  endpoints: build => ({
    getPokemonList: build.query<PokemonSummary[], void>({
      async queryFn(_arg, _api, _extra, baseQuery) {
        const res = await baseQuery('pokemon?limit=151');
        if (res.error) {
          return { error: res.error };
        }
        try {
          return { data: parsePokemonList(res.data) };
        } catch (err) {
          return {
            error: {
              status: 'CUSTOM_ERROR',
              error: err instanceof Error ? err.message : 'Invalid PokéAPI response',
            },
          };
        }
      },
      providesTags: ['PokemonList'],
    }),
  }),
});

export const { useGetPokemonListQuery } = listApi;

injectEndpoints añade getPokemonList al baseApi compartido y devuelve un hook tipado. El endpoint descarga los primeros 151 Pokémon en una sola petición, entrega el cuerpo en crudo a parsePokemonList, y devuelve o bien las filas ya con forma o bien un error capturado. providesTags: ['PokemonList'] sella el resultado con la etiqueta por la que el host invalidará. Como el hook lee la caché compartida, cualquier otro remote que pida los mismos datos recibe la copia en caché, sin una segunda petición.

La pantalla pierde su array hardcodeado y en su lugar lee el hook. apps/list/src/PokedexScreen.tsx:

export default function PokedexScreen({ onSelectPokemon, onLongPressPokemon }: PokedexScreenProps) {
  const insets = useSafeAreaInsets();
  const { data, isLoading, isError, refetch } = useGetPokemonListQuery();

  if (isLoading) {
    return (
      <View style={styles.centre}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  if (isError || !data) {
    return (
      <View style={styles.centre}>
        <Text style={styles.error}>Couldn't reach PokéAPI.</Text>
        <Pressable style={styles.retry} onPress={() => refetch()}>
          <Text style={styles.retryText}>Try again</Text>
        </Pressable>
      </View>
    );
  }

  return (
    <FlatList
      data={data}
      keyExtractor={p => String(p.id)}
      contentContainerStyle={{ paddingBottom: insets.bottom + 8 }}
      renderItem={({ item }) => (
        <Pressable
          style={styles.row}
          onPress={() => onSelectPokemon(item.id)}
          onLongPress={() => onLongPressPokemon?.(item.id)}>
          <Image source={{ uri: item.spriteUri }} style={styles.sprite} />
          <Text style={styles.number}>#{String(item.id).padStart(3, '0')}</Text>
          <Text style={styles.name}>{item.name}</Text>
        </Pressable>
      )}
    />
  );
}

PokedexScreenProps no cambió. La costura que tipó el post 5, el handler onSelectPokemon que el host pasa a través de ella, queda intacta. La subida de versión incompatible vino del nuevo export de runtime y las dependencias peer del paquete, no de las props. Lo que cambió es de dónde vienen los datos: la caché compartida del host, llenada por un endpoint que el remote inyectó, renderizada a través de un hook que nunca existió cuando se construyó el host.

Ahora rómpelo

La afirmación es que una sola instancia compartida lo mantiene todo unido. La forma más rápida de creértelo es quitarla y mirar.

Borra la entrada de @pokedex/contracts del mapa shared en las dos configs de rspack, dejando @reduxjs/toolkit y react-redux. Reconstruye y abre la pestaña Pokédex.

Gira. Para siempre. Sin crash, sin red box, y esta vez tampoco nada en la consola. Míralo el rato que quieras: no llega ni una línea.

Quizá esperabas el aviso del middleware de la sección del store. Nunca salta, y la razón de que nunca salte es la lección entera. Con el contrato ya no compartido, el host empaqueta su propia copia de @pokedex/contracts y el remote empaqueta otra aparte. Dos copias significan dos objetos baseApi. El store del host conectó el reducer y el middleware de su copia, así que para RTK el montaje está completo y sano: no falta nada, no hay nada que avisar. El remote list inyectó getPokemonList en la otra copia, una de la que el store nunca ha oído hablar. Así que el endpoint existe, el hook se ejecuta, y la petición que debería disparar no va a ninguna parte. Cada copia es coherente por dentro; el error está entre las dos, y en runtime nadie es dueño de ese “entre”.

Este es el fallo silencioso al que vuelven una y otra vez los posts sobre el singleton compartido. Dos Reacts petan a gritos al arrancar. Un store sin su middleware lanza un red box que nombra el problema. Dos baseApi no te dan ni lo uno ni lo otro: sin error, sin aviso, solo un spinner sobre una caché que nunca se llena, y el único diagnóstico es la ausencia de todo lo demás. Vuelve a poner la entrada compartida en las dos configs, reconstruye, y la lista se llena.

Invalidar a través de la costura

El grafo de tags ha estado ahí sin usarse. El host es dueño de la cabecera, y la cabecera recibe un control Refresh. apps/host/App.tsx:

import { useDispatch } from 'react-redux';
import { baseApi } from '@pokedex/contracts';

function RefreshButton() {
  const dispatch = useDispatch();
  return (
    <Pressable
      style={styles.refresh}
      onPress={() => dispatch(baseApi.util.invalidateTags(['PokemonList']))}
      hitSlop={12}
      accessibilityRole="button"
      accessibilityLabel="Refresh Pokédex">
      <Text style={styles.refreshText}>Refresh</Text>
    </Pressable>
  );
}

El post 4 escondió todas las cabeceras con headerShown: false en screenOptions. La pestaña Pokédex vuelve a encender la suya y monta el botón, en el mismo archivo:

<Tab.Screen
  name="Pokédex"
  component={PokedexTab}
  options={{ headerShown: true, headerRight: () => <RefreshButton /> }}
/>

RefreshButton necesita Pressable y Text añadidos al import de react-native, más dos pequeñas entradas de estilo; el archivo completo está en el tag del repo de acompañamiento.

El host nunca definió getPokemonList. No tiene ninguna referencia al endpoint del remote list, ni a su hook, ni a su query. Todo lo que despacha es un tag. invalidateTags(['PokemonList']) recorre la caché compartida, encuentra cada query que proveyó ese tag, y vuelve a pedir las que están en pantalla. Los datos del remote list se recargan, disparados por un botón en un módulo que no sabe nada de ellos.

Eso es el grafo de tags compartido hecho visible. En una app de verdad la invalidación cuelga del invalidatesTags de una mutación en lugar de de un botón, pero el alcance a través de la frontera del módulo es el mismo: una etiqueta, declarada en un lado, respetada en el otro, a través de la única caché que los dos comparten.

Ejecútalo

El contrato ya está publicado e instalado, así que Verdaccio no hace falta para la ejecución. Arranca cada remote y el host en su propia terminal, igual que en el post 5 pero ahora con el store conectado:

cd apps/list && npm run start:remote      # :8082
cd apps/profile && npm run start:remote   # :8083
cd apps/host && npm start                 # :8081
cd apps/host && npm run ios

La pestaña Pokédex muestra un spinner un momento, luego se llena con los primeros 151 Pokémon, sprites incluidos, directamente desde PokéAPI. Toca Refresh en la cabecera y la lista se recarga a través de la caché compartida.

La cáscara del host en iOS: la pestaña Pokédex mostrando una lista en vivo de Pokémon con sprites de official-artwork, servida por el remote list desde un store compartido, con un control Refresh en la cabecera del host

Lo que construiste, y lo que viene

El host es dueño de un store. El paquete de contratos es dueño del único baseApi en el que inyecta cada lado, así que la caché, la deduplicación, y el grafo de tags se comparten entre remotes que se construyeron y desplegaron por su cuenta. El remote list descarga datos en vivo a esa caché, protege la frontera con un esquema, y el host la invalida por tag sin importar ni una línea del código del remote.

Todo esto es estado de servidor, eso sí: datos de los que es dueño el servidor y de los que la caché guarda una copia. Nada de lo que es dueña la app ha cruzado todavía una frontera de módulo. Ningún Pokémon seleccionado, ningún filtro, ninguna sesión que un remote fija y otro lee. Eso es estado de cliente, y no vive en una caché de queries compartida; vive en slices que un remote inyecta en runtime.

Lo siguiente: estado de cliente a través de la costura. Los remotes dejan de tomar prestado el store del host y empiezan a añadirle cosas, inyectando sus propios reducers y despachando acciones a las que otros módulos reaccionan.

Fuentes

Warren de Leon
Warren de Leon

Software Engineering Manager. Recientemente lideré el equipo de Mobile Platform en Hargreaves Lansdown. Escribo sobre liderazgo técnico, React Native y cómo construir buenos equipos.

Ver perfil