El post anterior terminó con una frase con un agujero: una pantalla que renderizaba “Opened from the ” y nada más, porque un tipo comprobado en build no sirve de nada frente a un valor que solo aparece en runtime. También terminó con una promesa: datos reales, un store, compartido entre los remotes. Este post cumple la promesa, y el agujero recibe su respuesta de verdad por el camino.
Se apoya en estado de servidor y estado de cliente, del breve desvío de la serie. La división entre los datos que pertenecen a un servidor y los que pertenecen 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 el dominio Pokédex llena con sus dos endpoints, mientras la vista instalada de @pokedex/detail renderiza lo que le dan. Datos en vivo de PokéAPI reemplazan cada Pokémon hardcodeado de la app, incluidas las dos copias desviadas.
Esto es lo que vamos a construir, antes de cualquier código:
Lo único que hay que tener en la cabeza: baseApi es un solo 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 manera que merece la pena ver, así que lo vamos a romper a propósito cerca del final.
Continúa desde tu propio código del post 5 si lo has ido construyendo. Si no, empieza desde 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 estrena superficie de runtime
Hasta ahora @pokedex/contracts solo ha tenido tipos. Cada export se borraba en build, así que nada suyo llegaba a un bundle. Ahora incorpora su primer export de runtime: el objeto de API de RTK Query a través del cual hace fetch toda la app.
El host parece el sitio natural para él. El store vive en el host, la caché vive en el store, y un objeto de API junto al store que alimenta es donde lo pondría una app normal. En una app normal ese instinto es correcto. Bajo federación falla por identidad: un consumidor solo puede añadir endpoints a la misma instancia de baseApi que el store del host montó, y una instancia que vive dentro del código fuente del host es una que nada más puede importar. El paquete de contratos es el único módulo que cada lado ya resuelve a una sola copia, porque es un singleton de Module Federation. Pon la instancia ahí y el baseApi.injectEndpoints({...}) de un consumidor se registra contra la única caché y el único middleware que el store ya ejecuta. Una instancia significa una caché HTTP, una deduplicación de peticiones, un grafo de tags entre todas las features, incluidas las que se publiquen mucho después del shell. 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;
}
export interface PokemonDetail {
id: number;
name: string;
spriteUri: string;
types: string[];
}
const PokemonListResponseSchema = z.object({
// La url no es una string cualquiera: el id de la fila se deriva de ella, así que el esquema
// vigila el id numérico final que lleva una url de recurso de PokéAPI.
results: z.array(z.object({ name: z.string().min(1), url: z.string().regex(/\/\d+\/?$/) })),
});
const PokemonDetailResponseSchema = z.object({
id: z.number(),
name: z.string(),
types: z.array(z.object({ type: z.object({ name: 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+)\/?$/);
if (!match) {
throw new Error(`PokéAPI resource URL has no trailing id: ${url}`);
}
const id = Number(match[1]);
if (!Number.isSafeInteger(id) || id < 1) {
throw new Error(`PokéAPI resource URL id is out of range: ${url}`);
}
return id;
}
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) };
});
}
export function parsePokemonDetail(raw: unknown): PokemonDetail {
const parsed = PokemonDetailResponseSchema.parse(raw);
return {
id: parsed.id,
name: formatName(parsed.name),
spriteUri: artworkUri(parsed.id),
types: parsed.types.map(entry => formatName(entry.type.name)),
};
}
createApi sin endpoints construye un armazón vacío: un reducer, algo de middleware, y un método injectEndpoints que los consumidores van a llamar. fetchBaseQuery es un envoltorio pequeño sobre fetch que antepone la URL base y parsea el JSON. tagTypes nombra el único tag por el que esta app invalida; todavía no hace nada, y hace trabajo de verdad en la última sección.
Las dos funciones de parseo son la parte que señalaba la frase rota del post 5. Un tipo es una promesa de tiempo de compilación, y ya no existe cuando una respuesta aterriza de verdad. Un campo renombrado o un null donde antes había un string se cuela directo por un cast escrito a mano y revienta tres pantallas más allá. Así que cada respuesta cruda se valida con un esquema de Zod justo en la frontera, y una forma incorrecta se convierte en un valor que la pantalla puede manejar en vez de en un crash.
La frontera tiene que vigilar todo aquello de lo que depende, y por eso el esquema de la lista comprueba el id final de la url en lugar de conformarse con z.string(): el id se deriva de esa url, y una comprobación laxa aquí dejaría pasar un payload malformado como un Pokémon fantasma número 0. idFromResourceUrl lanza por el mismo motivo: un helper que se inventa un id en silencio ante una entrada mala es un parser que deja pasar datos malformados en vez de rechazarlos. La comprobación de rango también se gana sus dos líneas, porque una tira de dígitos al final todavía no es un id. /pokemon/0/ no nombra nada, y una cadena de dígitos lo bastante larga como para salirse del rango de enteros seguros ha dejado de ser el número impreso en la url.
El esquema del detail conserva solo lo que renderiza la pantalla de detail; el payload completo de PokéAPI para un Pokémon se acerca a los 300 KB de JSON, y parsear campos que nada muestra solo sería más superficie donde romperse. La validación en runtime tiene su propio post más adelante en la serie.
Un export de runtime y dos peer dependencies nuevas son un cambio que rompe, así que la versión sube de major. El número en sí necesita primero un vistazo a tu propio registro: la escalera del post 5 ya publicó 1.1.0 y 2.0.0, y un número publicado queda gastado para siempre, porque el registro se niega a reutilizarlo y cada install depende de esa negativa. Así que la frontera de runtime sale como 3.0.3. El major es lo que cuenta la historia; el patch final es el endurecimiento del parser que llegó después de la primera publicación. packages/contracts/package.json:
{
"name": "@pokedex/contracts",
"version": "3.0.3",
"dependencies": {
"zod": "^3.25.76"
},
"peerDependencies": {
"@reduxjs/toolkit": ">=2.10.0",
"react": "*",
"react-redux": ">=9"
}
}
zod es una dependencia real de runtime, así que viaja dentro del paquete. @reduxjs/toolkit y react-redux son peers: las apps los instalan, y el contrato usa sus copias en vez de empaquetar la suya. Instala las dependencias de desarrollo del paquete y publica:
cd packages/contracts
npm install
npm publish
+ @pokedex/contracts@3.0.3
El caret hace aquí lo que el post 5 mostró que hace. Cada consumidor está en ^1.0.0, así que npm install deja a las tres apps exactamente donde están aunque 3.0.3 ya exista en el registro. Adoptar el contrato nuevo es un movimiento deliberado, app por app, y una de las tres se queda quieta a propósito.
Un store en el host
El host suma un store. apps/host/src/store.ts:
import { combineSlices, configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@pokedex/contracts';
export const store = configureStore({
reducer: combineSlices(baseApi),
middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
El reparto de propiedad es el sentido del archivo. El store vive en el host: un store por app es trabajo del shell, como la barra de pestañas. La instancia de API alrededor de la que se construye el store vive en el paquete de contratos, por la razón de identidad de arriba. El host monta la conexión; el contrato pone la instancia.
baseApi.middleware es una pieza estructural. Ejecuta el ciclo de vida de la caché: fetching, deduplicación, invalidación por tags, expulsión de caché. Déjalo fuera del store y la primera pantalla que ejecute un hook de query lanza un red box a pantalla completa en desarrollo, y RTK nombra 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!
Merece la pena pararse en la pila de componentes de esa captura: el punto del crash es PokedexScreen dentro de __federation_expose_ListStack.chunk.bundle. La pantalla de un remote, en un chunk que el host descargó en runtime, choca con un error causado por una línea que falta en el archivo de store del propio host. El fallo cruza la frontera de módulos en la dirección contraria a todo lo demás de esta serie. Un fallo ruidoso, al menos. Tenlo presente, porque el sabotaje de Ahora rómpelo no recibe ningún error.
El store entra por encima de todo el árbol, para que todo lo federado dentro pueda leer la caché. apps/host/App.tsx, el envoltorio:
import { Provider } from 'react-redux';
import { store } from './src/store';
export default function App() {
return (
<Provider store={store}>
<SafeAreaProvider>
<NavigationContainer>
{/* el navegador de pestañas del post 4 */}
</NavigationContainer>
</SafeAreaProvider>
</Provider>
);
}
Los remotes nunca crean ni importan un store. Se renderizan dentro de este árbol y llegan al store a través del singleton compartido de react-redux, igual que llegaban al contexto compartido de safe-area en el post 3.
Comparte el trío de estado
Para que algo fuera del host inyecte en el baseApi compartido, tres paquetes tienen que resolverse a una sola copia en runtime: @reduxjs/toolkit, react-redux, y el propio @pokedex/contracts. Se unen al mapa de shared del host como singletons eager, y la lección que tanto costó en el post 5 se aplica a dos de ellos de inmediato. apps/host/rspack.config.mjs, las adiciones:
import rtkPkg from '@reduxjs/toolkit/package.json' with { type: 'json' };
import reactReduxPkg from 'react-redux/package.json' with { type: 'json' };
// ...en el mapa shared:
'@reduxjs/toolkit': {
singleton: true,
eager: true,
version: rtkPkg.version,
requiredVersion: pkg.dependencies['@reduxjs/toolkit'],
},
'react-redux': {
singleton: true,
eager: true,
version: reactReduxPkg.version,
requiredVersion: pkg.dependencies['react-redux'],
},
'@pokedex/contracts': {
singleton: true,
eager: true,
requiredVersion: pkg.dependencies['@pokedex/contracts'],
},
Un paquete resuelto a través de un mapa exports necesita declarar su version a mano. Los dos paquetes de Redux lo hacen, y @pokedex/contracts no. El post 5 encontró esto en el mismo montaje: rspack lee la versión del paquete que está compartiendo, y omite el provide para un paquete resuelto a través de un mapa exports. Se sostuvo en estas versiones, en esta configuración, que es lo que se afirma aquí. @reduxjs/toolkit y react-redux llevan los dos un mapa exports, así que sin el version explícito ninguno aterrizaría en el share scope. El paquete de contratos no tiene mapa exports, así que no necesita nada. El grep del bundle del post 5 confirma que los tres provides se registran, contrato incluido:
"@pokedex/contracts", version: "3.0.3"
"@reduxjs/toolkit", version: "2.12.0"
"react-redux", version: "9.3.0"
El remote list refleja las mismas tres entradas sin eager: el host provee las copias, el remote las consume. Es la primera vez que @pokedex/contracts aparece en un mapa shared. Hasta ahora era solo tipos, borrados en build, así que no había nada que compartir. Ahora lleva baseApi, y una sola instancia es todo el sentido.
Dos de las apps añaden los paquetes y avanzan el contrato:
( cd apps/host && npm install @reduxjs/toolkit@2.12.0 react-redux@9.3.0 @pokedex/contracts@3.0.3 )
( cd apps/list && npm install @reduxjs/toolkit@2.12.0 react-redux@9.3.0 @pokedex/contracts@3.0.3 )
La app party es la tercera, y no recibe nada. Ni paquetes de Redux, ni entradas shared nuevas, y sus dos paquetes instalados se quedan donde están: el contrato en ^1.0.0 (dos majors por detrás del contrato contra el que tipa) y la pantalla de detail en ^1.0.0, la versión estática. Eso es seguro por una razón precisa: la app party consume solo tipos del contrato, y los tipos se borran en build. Un major de runtime cambia lo que viaja en el JavaScript del paquete; un consumidor que nunca importa nada de ese JavaScript no tiene nada que romper. Los remotes entran en el estado compartido por decisión propia; el shell no les impone nada.
El remote list: datos reales
Ahora la primera inyección. apps/list/src/listApi.ts, un archivo nuevo:
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 trae los primeros 151 Pokémon en una sola petición, pasa el cuerpo crudo a parsePokemonList, y devuelve o las filas con forma o un error capturado. providesTags: ['PokemonList'] estampa el resultado con el tag por el que el host va a invalidar. El shell no sabía nada de este endpoint cuando se construyó; el remote lo añade al store en marcha la primera vez que su código carga.
La pantalla se deshace de su array hardcodeado de cinco filas y lee el hook. apps/list/src/PokedexScreen.tsx:
export default function PokedexScreen() {
const insets = useSafeAreaInsets();
const navigation = useNavigation<NativeStackNavigationProp<ListParamList>>();
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={() => navigation.navigate('PokemonDetail', { id: 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>
)}
/>
);
}
Tres estados en vez de uno: un spinner mientras la petición está en curso, una pantalla de error con reintento cuando PokéAPI no responde, y la lista. La navegación del post 5 está intacta: tocar una fila sigue empujando PokemonDetail con { id } dentro del stack propio de este remote. Lo que cambió es de dónde salen las filas: una caché en el store del host, llenada por un endpoint que este remote inyectó, leída a través de un hook que no existía cuando el host se publicó.
El detail cobra vida; la librería sigue siendo una vista
El post 5 dejó un olor deliberado en el paquete de detail: su propia copia privada de los datos de Pokémon, desviada de la copia del list, con un comentario prometiendo que los datos en vivo la borrarían. Este es ese momento, y llegan dos releases que trazan una línea.
La línea: una librería de componentes publica píxeles, y las definiciones de datos viven con el dominio de esos datos. El próximo post defiende esa línea a fondo; este la aplica. Los datos de Pokémon son del dominio Pokédex, así que el segundo endpoint aterriza junto al primero, en la app list. apps/list/src/detailApi.ts:
import { baseApi, parsePokemonDetail, type PokemonDetail } from '@pokedex/contracts';
const detailApi = baseApi.injectEndpoints({
endpoints: build => ({
getPokemonDetail: build.query<PokemonDetail, number>({
async queryFn(id, _api, _extra, baseQuery) {
const res = await baseQuery(`pokemon/${id}`);
if (res.error) {
return { error: res.error };
}
try {
return { data: parsePokemonDetail(res.data) };
} catch (err) {
return {
error: {
status: 'CUSTOM_ERROR',
error: err instanceof Error ? err.message : 'Invalid PokéAPI response',
},
};
}
},
}),
}),
});
export const { useGetPokemonDetailQuery } = detailApi;
Una query que toma el id como argumento, trae un Pokémon, y lo parsea en la frontera. Sin tags: nada invalida un solo Pokémon todavía.
@pokedex/detail sube a 3.0.0: un major, porque las props cambian de forma por completo. La copia estática y su búsqueda se han ido; lo que queda es una vista: PokemonDetailView toma el Pokémon y los tres estados como props, los renderiza, y no hace nada más. La app list compone hook y vista en un contenedor pequeño en su ruta de detail:
import { PokemonDetailView } from '@pokedex/detail';
import { useGetPokemonDetailQuery } from './detailApi';
function PokemonDetailRoute({ route }: { route: { params: DetailParams } }) {
const { data, isLoading, isError, refetch } = useGetPokemonDetailQuery(route.params.id);
return <PokemonDetailView pokemon={data} loading={isLoading} error={isError} onRetry={refetch} />;
}
De dónde salen los datos es asunto de la app; qué aspecto tienen es asunto de la librería. Nada en el paquete toca el store, y pasar a datos en vivo no le añadió ni una dependencia: los mismos cuatro peers que tenía la versión estática, y Redux no aparece entre ellos.
Suma el coste de cobrar vida: un archivo de endpoint en el dominio de esos datos, un major honesto en la vista, y un contenedor de cuatro líneas. Y la recompensa más silenciosa: la copia de datos del list y la copia del paquete se han ido las dos, así que la deriva entre ellas también se ha ido. Las dos pantallas renderizan ahora lo que diga PokéAPI, a través de una caché, y contradecirse la una a la otra ya no es algo que sepan hacer.
Ahora rómpelo
La afirmación es que una sola instancia compartida lo sostiene todo. La manera más rápida de fiarte es quitarla y mirar.
Borra la entrada @pokedex/contracts del mapa shared en las dos configs que la llevan, host y list, y deja @reduxjs/toolkit y react-redux en su sitio. Reinicia los servidores de desarrollo y relanza.
El spinner gira. Para siempre. Sin crash, sin red box, y esta vez tampoco nada en la consola. Veinte segundos después, la única línea que el servidor de desarrollo ha registrado es el arranque de la app. Míralo el rato que quieras: no llega nada más.
El aviso del middleware de la sección del store parecería el fallo natural aquí, y la razón de que nunca salte es la lección entera. Con el contrato sin compartir, el host empaqueta su propia copia de @pokedex/contracts y el remote list empaqueta otra distinta. Dos copias significan dos objetos baseApi. El store del host montó el reducer y el middleware de su copia, así que desde donde mira RTK el montaje está completo y sano: no falta nada, nada de que avisar. Los dos endpoints del dominio se registraron en la copia del list, una que ningún store montó jamás, así que los endpoints existen, los hooks corren, y los fetch que deberían disparar no van a ninguna parte. Cada copia es internamente consistente. El error está entre ellas, y en runtime ese «entre» no es de nadie.
Este es el fallo silencioso al que los posts del singleton compartido vuelven una y otra vez, y ya tiene una escalera completa. Dos Reacts revientan al arrancar. Un middleware que falta lanza un red box que nombra el archivo a arreglar. Dos baseApi te dan un spinner sobre una caché que nunca se llena, y el único diagnóstico es la ausencia de todo lo demás. Un fallo ruidoso es barato; el silencioso cuesta la tarde. Devuelve las entradas shared, reinicia, y la lista se llena otra vez.
La invalidación cruza la frontera
El grafo de tags lleva sin usarse desde que el contrato lo declaró. Toca tirar de él.
El post 4 escondió todas las cabeceras con headerShown: false en el navegador de pestañas, y el post 5 le dio a cada stack de remote sus propias cabeceras dentro de la pestaña. El host ahora vuelve a encender la cabecera de la pestaña Pokédex, porque va a poner en ella algo que es suyo. 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>
);
}
import { getFocusedRouteNameFromRoute } from '@react-navigation/native';
<Tab.Screen
name="Pokédex"
component={PokedexTab}
options={({ route }) => ({
headerShown: getFocusedRouteNameFromRoute(route) !== 'PokemonDetail',
headerRight: () => <RefreshButton />,
})}
/>
Un detalle de ahí merece mirarse de cerca. La ruta de detail trae su propia cabecera de stack con botón de volver, y una cabecera de pestaña apilada encima pondría dos barras en pantalla. Así que el host esconde su barra mientras el stack está en el detail, y la enseña en cuanto el stack vuelve atrás.
Comprobar contra 'PokemonDetail' parece que el host se mete en los nombres de ruta internos del remote, y eso es exactamente lo que sería si el nombre viviera en el remote. Pero no vive ahí. Es el nombre de ruta de DetailParamList en @pokedex/contracts, el mismo acuerdo del que salen los params. Sé honesto sobre hasta dónde llega eso: el host escribe la cadena 'PokemonDetail' a mano, y TypeScript no conecta una cadena suelta con la clave del contrato. Renombra la ruta en el contrato y esta comparación sigue compilando mientras deja de coincidir en silencio. El acuerdo existe; el compilador no está imponiendo este extremo.
RefreshButton necesita Pressable y Text añadidos al import de react-native, más dos entradas pequeñas de estilo; el archivo completo está en el tag de acompañamiento.
El host nunca definió getPokemonList. No guarda ninguna referencia al endpoint del remote list, ni a su hook, ni a su query. Lo único que despacha es un tag. invalidateTags(['PokemonList']) recorre la caché compartida, encuentra cada query que proveyó ese tag, y vuelve a pedir las que tienen un suscriptor en pantalla. La lista se recarga; el endpoint del detail, que no provee tags, queda intacto. Un tag, declarado en el contrato, provisto por una feature, despachado por el host, y el refetch aterriza en código que quien despacha no ha visto nunca.
En una app real la invalidación cuelga del invalidatesTags de una mutation en vez de un botón, pero el alcance a través de la frontera de módulos es el mismo. Los equipos acuerdan nombres de tags en el contrato igual que acuerdan tipos, y ese acuerdo es el protocolo entero de refresco entre ellos.
Termina exactamente en el estado final de este post. El recorrido imprime los archivos que sostienen el build; los manifests, las configs, los tests y los cambios menores viven en el tag. Para acabar con un árbol idéntico byte a byte al del tag, vuelca encima la copia de referencia. Un archivo que escribiste bien se sobrescribe consigo mismo, y el volcado rellena lo que la prosa no imprimió:
npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-06-shared-store /tmp/pokedex-ref-06
rm packages/detail/src/PokemonDetailScreen.tsx
cp -R /tmp/pokedex-ref-06/. .
El rm importa: este post borra esa pantalla, y un volcado sobre el árbol añade y sobrescribe, pero nunca elimina.
El barrido trae además la configuración del transform de Jest que necesita el stack del store, los tests de regresión del parser, los cambios de stack por pestaña y el refactor del detail que este recorrido describió en prosa.
Ponlo en marcha
Los paquetes están publicados e instalados, así que Verdaccio no hace falta para la ejecución. Tres servidores de desarrollo, uno por app, y la build del simulador:
cd apps/list && npm run start:remote # :8082
cd apps/party && npm run start:remote # :8083
cd apps/host && npm start # :8081
cd apps/host && npm run ios
La pestaña Pokédex enseña un spinner un momento, y luego se llena con los primeros 151 Pokémon, artwork oficial incluido, directos de PokéAPI. Toca una fila y la ruta de detail trae su Pokémon a través de la misma caché. Toca Refresh en la cabecera y la lista se recarga al otro lado de la frontera:
La pestaña Party sigue renderizando su cuadrícula vacía, con el contrato dos majors por detrás, la pantalla de detail todavía en la 1.0.0 estática, sin store a la vista, funcionando exactamente igual que antes de este post. La independencia incluye la libertad de no participar.
Lo que has construido, y lo que viene
El paquete de contratos define el único baseApi en el que inyecta cada lado, así que la caché, la deduplicación y el grafo de tags se comparten entre features que se construyeron y publicaron por su cuenta. El host monta el store construido a su alrededor. El dominio Pokédex llena la caché con sus dos endpoints, la vista instalada renderiza lo que los contenedores le dan, la frontera queda protegida con un esquema, y el host refresca datos que no sabe nombrar despachando un tag. Sin las entradas shared, el fallo es un spinner silencioso; ya lo has visto una vez aquí, así que lo reconocerás cuando sea caro.
Todo lo que cruza la frontera sigue siendo estado de servidor, eso sí: datos que pertenecen a un servidor y de los que la caché guarda una copia. Nada que pertenezca a la propia app ha cruzado todavía una frontera de módulos, y la app empieza a acumular cosas suyas. La cuadrícula del Party sigue vacía porque nada de lo que haces en la Pokédex puede llegarle. Cierra la app y vuélvela a abrir, y nada de lo que hiciste sobrevive: ni party, ni favoritos, ni memoria.
El estado de cliente es la otra mitad de la división de la que partió este post, y el build-along vuelve a por él dentro de dos posts. Antes, la serie da un paso atrás y defiende las decisiones de propiedad que los últimos tres posts fueron tomando una a una: dónde va una frontera, qué puede saber un componente compartido, dónde vive una definición de datos.
Fuentes
- Redux Toolkit: code splitting —
injectEndpointsy añadir endpoints a una API existente en runtime - RTK Query — la caché, los tags y los hooks generados
- Zod — la librería de esquemas que protege la frontera de runtime
- PokéAPI — la API REST gratuita de la que hace fetch la app
- react-native-module-federation — el repo de acompañamiento, en el tag
post-06-shared-store