El post anterior acabava amb una promesa: les pestanyes deixen de tenir dades hardcodejades i comparteixen un sol store entre els remotes, amb dades reals d’una API. Aquest post la compleix.
Es recolza en estat de servidor i estat de client, del breu desviament de la sèrie. La divisió entre les dades que són propietat d’un servidor i les que són propietat de l’app es dona aquí per sabuda, no es torna a explicar. Aquest post tracta de la meitat, l’estat de servidor, sota federació: un sol store de Redux Toolkit (RTK) al host, un sol cache d’RTK Query que els remotes comparteixen, i dades en directe de PokéAPI que substitueixen els cinc noms que la llista arrossega des del post 2.
La forma que construïm, abans de cap línia de codi:
L’única cosa que has de retenir: baseApi és un sol objecte, i cada costat importa exactament el mateix. Això és el que fa que el cache sigui compartit. Trenca-ho, i l’app es trenca d’una manera que val la pena veure, així que la trencarem a propòsit cap al final.
Continua amb el teu propi codi del post 5 si ho vas construir pas a pas. Si no, parteix del seu estat final:
git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-05-contracts
El paquet de contractes guanya una costura en temps d’execució
Fins ara @pokedex/contracts només ha contingut tipus. Cada export s’esborrava en construir, així que res d’ell arribava a un bundle. Ara guanya el seu primer export de runtime: l’objecte API d’RTK Query a través del qual tota l’app fa les seves peticions.
Viu aquí, al paquet compartit, i no al host, per una raó. Un remote federat només pot afegir els seus endpoints a la mateixa instància de baseApi que el store del host va connectar. Com que @pokedex/contracts és un singleton de Module Federation, el host i cada remote importen aquest mateix objecte. Així que el baseApi.injectEndpoints({...}) d’un remote es registra contra l’únic cache i middleware que el store ja executa. Una sola instància vol dir un sol cache HTTP, un sol pipeline de deduplicació, un sol graf de tags entre tots els remotes, inclosos els remotes que es despleguen molt després de la closca. 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 sense endpoints construeix una closca buida: un reducer, una mica de middleware, i un mètode injectEndpoints que els remotes cridaran. fetchBaseQuery és un petit embolcall sobre fetch que anteposa la URL base i parseja el JSON. tagTypes anomena l’única etiqueta per la qual aquesta app invalida; de moment no fa res, i fa feina de debò a l’última secció.
parsePokemonList és la part que assenyalava el post 5. El contracte conté el tipus en què els dos costats es posen d’acord, però un tipus és una promesa en temps de compilació, i ja no hi és quan una resposta realment arriba. Un camp reanomenat o un null on abans hi havia un string es cola directament a través d’un cast escrit a mà i peta tres pantalles més enllà. Així que la resposta crua es valida amb un esquema de Zod just a la costura, i una forma dolenta es converteix en un valor que podem gestionar en lloc d’un crash. La validació en temps d’execució té el seu propi post més endavant; aquí és la salvaguarda de la frontera a través de la qual s’omple el cache compartit.
Afegir un export de runtime i dues peer dependencies és un canvi incompatible, així que la versió puja 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 és una dependència de runtime de debò, així que s’inclou dins del paquet. @reduxjs/toolkit i react-redux són peers: el host les instal·la, i el contracte pren prestades les còpies del host en lloc d’incloure les seves pròpies. Abans, un pas de neteja: si vas executar la demo de canvi incompatible del post 5, el teu registre ja té la 2.0.0 d’ids com a string, i Verdaccio es nega a publicar sobre una versió existent. Elimina-la:
npm unpublish @pokedex/contracts@2.0.0 --registry http://localhost:4873
Després instal·la les dependències de desenvolupament del paquet i publica el nou major de debò:
cd packages/contracts
npm install
npm publish
+ @pokedex/contracts@2.0.0
El post 5 va mostrar el caret rebutjant un major pel seu compte. Encara ho fa. El host i el list estan a ^1.1.0, així que npm install els deixa a 1.1.0 fins i tot ara que existeix 2.0.0. Agafar el nou contracte és un moviment deliberat, un rang cada cop, que és el següent que fem.
Un sol store al host
El host guanya 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 és el root reducer d’RTK 2.x que pot rebre més slices en temps d’execució. Un remote cridarà rootReducer.inject(...) per afegir els seus propis reducers més endavant; això és estat de client, i el pròxim post. De moment l’únic slice és el de baseApi: el cache d’estat de servidor compartit.
baseApi.middleware és estructural. Executa el cicle de vida del cache: descàrregues, deduplicació, invalidació de tags, expulsió del cache. Deixa’l fora del store i la primera query llança una caixa vermella en desenvolupament, amb RTK anomenant l’error sense embuts:
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!
Una fallada sorollosa, doncs. Tingues-ho present, perquè el sabotatge del final d’aquest post no rep cap avís.
El store es col·loca per sobre de tot l’arbre, així que cada remote federat dins seu pot llegir el cache. apps/host/App.tsx, l’embolcall:
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>
);
}
Els remotes mai no creen ni importen un store. Es renderitzen dins d’aquest arbre de React i hi arriben a través del singleton compartit de react-redux, igual que arribaven al context compartit de safe-area al post 3.
Comparteix el trio d’estat
Perquè un remote injecti al baseApi del host, tres paquets han de resoldre’s a una sola còpia en temps d’execució: @reduxjs/toolkit, react-redux, i el mateix @pokedex/contracts. Afegeix-los a la configuració compartida del host com a singletons eager. apps/host/rspack.config.mjs, els afegits 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 els mateixos tres, singleton: true però no eager: consumeix les còpies del host des del share scope en lloc de proporcionar les seves. És el primer cop que @pokedex/contracts apareix en un mapa shared. Fins ara era només tipus, esborrat en construir, així que no hi havia res a compartir. Ara porta baseApi, i una sola instància és tot el sentit.
Les dues apps també afegeixen els paquets a les seves dependencies i pugen el contracte:
( 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 rep res. Segueix renderitzant la seva targeta d’entrenador estàtica, i mai no toca el store. Els remotes opten per l’estat compartit; la closca no els l’imposa.
El remote list: dades reals
Ara el remote injecta el seu 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 afegeix getPokemonList al baseApi compartit i retorna un hook tipat. L’endpoint descarrega els primers 151 Pokémon en una sola petició, passa el cos cru a parsePokemonList, i retorna o bé les files estructurades o bé un error capturat. providesTags: ['PokemonList'] marca el resultat amb l’etiqueta per la qual el host invalidarà. Com que el hook llegeix el cache compartit, qualsevol altre remote que demani les mateixes dades rep la còpia en cache, sense una segona petició.
La pantalla perd el seu array hardcodejat i llegeix el hook en el seu lloc. 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 ha canviat. La costura que el post 5 va tipar, el handler onSelectPokemon que el host hi passa, queda intacta. La pujada de versió incompatible va venir del nou export de runtime i les peer dependencies del paquet, no de les props. El que ha canviat és d’on vénen les dades: el cache compartit del host, omplert per un endpoint que el remote va injectar, renderitzat a través d’un hook que mai no va existir en el moment de construir el host.
Ara trenca-ho
L’afirmació és que una sola instància compartida ho manté tot junt. La manera més ràpida de creure-t’ho és treure-la i mirar.
Esborra l’entrada @pokedex/contracts del mapa shared en totes dues configs d’rspack, deixant @reduxjs/toolkit i react-redux. Reconstrueix i obre la pestanya Pokédex.
Gira. Per sempre. Cap crash, cap caixa vermella, i aquesta vegada tampoc res a la consola. Mira-t’ho l’estona que vulguis: no arriba ni una línia.
Potser esperaves l’avís del middleware de la secció del store. No salta mai, i la raó per la qual no salta mai és tota la lliçó. Amb el contracte ja no compartit, el host empaqueta la seva pròpia còpia de @pokedex/contracts i el remote n’empaqueta una de separada. Dues còpies vol dir dos objectes baseApi. El store del host va connectar el reducer i el middleware de la seva còpia, així que per a RTK el muntatge és complet i sa: no hi falta res, no hi ha res per avisar. El remote list va injectar getPokemonList a l’altra còpia, una de la qual el store no ha sentit a parlar mai. Així que l’endpoint existeix, el hook s’executa, i la descàrrega que hauria de disparar no va enlloc. Cada còpia és coherent per dins; l’error és entre totes dues, i en temps d’execució ningú no és propietari d’aquest “entre”.
Aquesta és la fallada silenciosa a la qual tornen una vegada i una altra els posts del singleton compartit. Dos Reacts peten sorollosament en arrencar. Un store sense el seu middleware llança una caixa vermella que anomena el problema. Dos baseApi no et donen ni una cosa ni l’altra: cap error, cap avís, només un spinner sobre un cache que no s’omple mai, i l’únic diagnòstic és l’absència de tota la resta. Torna a posar l’entrada compartida a totes dues configs, reconstrueix, i la llista s’omple.
Invalida a través de la costura
El graf de tags ha estat sense fer-se servir. El host és el propietari de la capçalera, i la capçalera guanya 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 amagava totes les capçaleres amb headerShown: false a screenOptions. La pestanya Pokédex torna a encendre la seva i hi munta el botó, al mateix fitxer:
<Tab.Screen
name="Pokédex"
component={PokedexTab}
options={{ headerShown: true, headerRight: () => <RefreshButton /> }}
/>
RefreshButton necessita Pressable i Text afegits a l’import de react-native, més dues petites entrades d’estil; el fitxer complet és al tag del repo d’acompanyament.
El host mai no va definir getPokemonList. No té cap referència a l’endpoint del remote list, ni al seu hook, ni a la seva query. Tot el que fa dispatch és un tag. invalidateTags(['PokemonList']) recorre el cache compartit, troba cada query que va proporcionar aquell tag, i torna a descarregar les que són a pantalla. Les dades del remote list es tornen a carregar, disparades per un botó en un mòdul que no en sap res.
Això és el graf de tags compartit fet visible. En una app de debò la invalidació penja del invalidatesTags d’una mutació en lloc d’un botó, però l’abast a través de la frontera del mòdul és el mateix: una etiqueta, declarada a un costat, respectada a l’altre, a través de l’únic cache que tots dos comparteixen.
Executa’l
El contracte ja està publicat i instal·lat, així que Verdaccio no cal per a l’execució. Arrenca cada remote i el host a la seva pròpia terminal, igual que al post 5 però amb el store ara connectat:
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 pestanya Pokédex mostra un spinner un moment, després s’omple amb els primers 151 Pokémon, sprites inclosos, directament de PokéAPI. Toca Refresh a la capçalera i la llista es torna a carregar a través del cache compartit.
Què has construït, i què ve
El host és el propietari d’un sol store. El paquet de contractes és el propietari de l’únic baseApi on tots els costats injecten, així que el cache, la deduplicació, i el graf de tags es comparteixen entre remotes que es van construir i desplegar pel seu compte. El remote list descarrega dades en directe cap a aquell cache, protegeix la frontera amb un esquema, i el host l’invalida per tag sense importar ni una línia del codi del remote.
Ara bé, tot això és estat de servidor: dades que són propietat del servidor i de les quals el cache guarda una còpia. Res que sigui propietat de l’app ha creuat encara una frontera de mòdul. Cap Pokémon seleccionat, cap filtre, cap sessió que un remote estableix i un altre llegeix. Això és estat de client, i no viu en un cache de query compartit; viu en slices que un remote injecta en temps d’execució.
El següent: estat de client a través de la costura. Els remotes deixen de prendre prestat el store del host i comencen a afegir-hi, injectant els seus propis reducers i fent dispatch d’accions a les quals reaccionen altres mòduls.
Fonts
- Redux Toolkit: code splitting —
injectEndpointsi afegir endpoints a una API existent en temps d’execució - RTK Query — el cache, els tags, i els hooks generats
- Zod — la llibreria d’esquemes que protegeix la frontera en temps d’execució
- PokéAPI — l’API REST gratuïta d’on es descarrega la llista
- react-native-module-federation — el repo d’acompanyament, al tag
post-06-shared-store