The last post ended on a promise: the tabs stop holding hardcoded data and share one store across the remotes, with real data from an API. This post keeps it.
It leans on server state and client state from the short break in the series. The split between data a server owns and data the app owns is assumed here, not re-explained. This post is about one half of it, server state, under federation: one Redux Toolkit (RTK) store in the host, one RTK Query cache the remotes share, and live data from PokéAPI replacing the five names the list has carried since post 2.
The shape we’re building, before any code:
The one thing to hold in your head: baseApi is a single object, and every side imports the exact same one. That is what makes the cache shared. Break that, and the app breaks in a way worth seeing, so we will break it on purpose near the end.
Carry on from your own post 5 code if you built along. Otherwise, start from its finished state:
git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-05-contracts
The contract package grows a runtime seam
So far @pokedex/contracts has held only types. Every export was erased at build, so nothing from it reached a bundle. Now it gains its first runtime export: the RTK Query API object the whole app fetches through.
It lives here, in the shared package, and not in the host, for one reason. A federated remote can only add its endpoints to the same baseApi instance the host store wired in. Because @pokedex/contracts is a Module Federation singleton, the host and every remote import this exact object. So a remote’s baseApi.injectEndpoints({...}) registers against the one cache and middleware the store already runs. One instance means one HTTP cache, one deduplication pipeline, one tag graph across every remote, including remotes shipped long after the 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;
}
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 with no endpoints builds an empty shell: a reducer, some middleware, and an injectEndpoints method the remotes will call. fetchBaseQuery is a small wrapper over fetch that prepends the base URL and parses JSON. tagTypes names the one label this app invalidates by; it does nothing yet, and does real work in the last section.
parsePokemonList is the part post 5 pointed at. The contract holds the type both sides agree on, but a type is a compile-time promise, and it is gone by the time a response actually lands. A renamed field or a null where a string used to be slips straight through a hand-written cast and crashes three screens later. So the raw response is validated with a Zod schema right at the seam, and a bad shape becomes a value we can handle instead of a crash. Runtime validation gets a post of its own later on; here it is the boundary guard the shared cache is filled through.
Adding a runtime export and two peer dependencies is a breaking change, so the version goes to 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 is a real runtime dependency, so it ships inside the package. @reduxjs/toolkit and react-redux are peers: the host installs them, and the contract borrows the host’s copies rather than bundling its own. One housekeeping step first: if you ran post 5’s breaking-change demo, your registry already holds the string-id 2.0.0, and Verdaccio refuses to publish over an existing version. Remove it:
npm unpublish @pokedex/contracts@2.0.0 --registry http://localhost:4873
Then install the package’s dev dependencies and publish the real new major:
cd packages/contracts
npm install
npm publish
+ @pokedex/contracts@2.0.0
Post 5 showed the caret refusing a major on its own. It still does. The host and list sit on ^1.1.0, so npm install leaves them on 1.1.0 even now that 2.0.0 exists. Picking up the new contract is a deliberate move, one range at a time, which is the next thing we do.
One store in the host
The host gains a 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 is the RTK 2.x root reducer that can take more slices at runtime. A remote will call rootReducer.inject(...) to add its own reducers later; that is client state, and the next post. For now the only slice is baseApi’s: the shared server-state cache.
baseApi.middleware is load-bearing. It runs the cache lifecycle: fetching, deduplication, tag invalidation, cache eviction. Leave it off the store and the first query throws a red box in development, with RTK naming the mistake outright:
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!
A loud failure, then. Keep it in mind, because the sabotage near the end of this post gets no warning at all.
The store goes in over the whole tree, so every remote federated into it can read the cache. apps/host/App.tsx, the 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>
);
}
Remotes never create or import a store. They are rendered inside this React tree and reach it through the shared react-redux singleton, the same way they reached the shared safe-area context back in post 3.
Share the state trio
For a remote to inject into the host’s baseApi, three packages have to resolve to one copy at runtime: @reduxjs/toolkit, react-redux, and @pokedex/contracts itself. Add them to the host’s shared config as eager singletons. apps/host/rspack.config.mjs, the additions to 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'],
},
The list remote declares the same three, singleton: true but not eager: it consumes the host’s copies from the share scope rather than providing its own. This is the first time @pokedex/contracts appears in a shared map. Until now it was types-only, erased at build, so there was nothing to share. Now it carries baseApi, and one instance is the whole point.
Both apps also add the packages to their dependencies and bump the contract:
( 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 )
The profile remote gets nothing. It still renders its static trainer card, and it never touches the store. Remotes opt into shared state; the shell does not force it on them.
The list remote: real data
Now the remote injects its 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 adds getPokemonList to the shared baseApi and hands back a typed hook. The endpoint fetches the first 151 Pokémon in one request, hands the raw body to parsePokemonList, and returns either the shaped rows or a caught error. providesTags: ['PokemonList'] stamps the result with the label the host will invalidate by. Because the hook reads the shared cache, any other remote that asks for the same data gets the cached copy, no second request.
The screen loses its hardcoded array and reads the hook instead. 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 did not change. The seam post 5 typed, the onSelectPokemon handler the host passes across it, is untouched. The breaking version bump came from the package’s new runtime export and peer dependencies, not from the props. What changed is where the data comes from: the host’s shared cache, filled by an endpoint the remote injected, rendered through a hook that never existed at the host’s build time.
Now break it
The claim is that one shared instance holds it all together. The fastest way to trust that is to remove it and watch.
Delete the @pokedex/contracts entry from the shared map in both rspack configs, leaving @reduxjs/toolkit and react-redux. Rebuild and open the Pokédex tab.
It spins. Forever. No crash, no red box, and this time nothing in the console either. Watch it for as long as you like: not one line arrives.
You might have expected the middleware warning from the store section. It never fires, and the reason it never fires is the whole lesson. With the contract no longer shared, the host bundles its own copy of @pokedex/contracts and the remote bundles a separate one. Two copies means two baseApi objects. The host store wired the reducer and middleware of its copy, so from where RTK stands the setup is complete and healthy: nothing is missing, nothing to warn about. The list remote injected getPokemonList into the other copy, one the store has never heard of. So the endpoint exists, the hook runs, and the fetch it should trigger goes nowhere. Each copy is internally consistent; the mistake sits between them, and nothing at runtime owns “between”.
This is the quiet failure the shared-singleton posts keep returning to. Two Reacts crash loudly on launch. A store without its middleware throws a red box that names the problem. Two baseApis give you neither: no error, no warning, just a spinner over a cache that never fills, and the only diagnostic is the absence of everything else. Put the shared entry back in both configs, rebuild, and the list fills.
Invalidate across the seam
The tag graph has been sitting unused. The host owns the header, and the header gets a Refresh control. 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>
);
}
Post 4 hid every header with headerShown: false in screenOptions. The Pokédex tab turns its own header back on and mounts the button, in the same file:
<Tab.Screen
name="Pokédex"
component={PokedexTab}
options={{ headerShown: true, headerRight: () => <RefreshButton /> }}
/>
RefreshButton needs Pressable and Text added to the react-native import, plus two small style entries; the complete file is in the companion tag.
The host never defined getPokemonList. It holds no reference to the list remote’s endpoint, its hook, or its query. All it dispatches is a tag. invalidateTags(['PokemonList']) walks the shared cache, finds every query that provided that tag, and refetches the ones on screen. The list remote’s data reloads, triggered by a button in a module that knows nothing about it.
That is the shared tag graph made visible. In a real app the invalidation hangs off a mutation’s invalidatesTags rather than a button, but the reach across the module boundary is the same: one label, declared on one side, honoured on the other, through the single cache both share.
Run it
The contract is published and installed, so Verdaccio is not needed for the run. Start each remote and the host in its own terminal, same as post 5, now with the store wired in:
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
The Pokédex tab shows a spinner for a moment, then fills with the first 151 Pokémon, sprites included, straight from PokéAPI. Tap Refresh in the header and the list reloads through the shared cache.
What you built, and what’s next
The host owns one store. The contract package owns the one baseApi every side injects into, so the cache, the deduplication, and the tag graph are shared across remotes that were built and shipped on their own. The list remote fetches live data into that cache, guards the boundary with a schema, and the host invalidates it by tag without importing a line of the remote’s code.
All of it is server state, though: data the server owns and the cache holds a copy of. Nothing the app owns has crossed a module boundary yet. No selected Pokémon, no filter, no session that one remote sets and another reads. That is client state, and it does not live in a shared query cache; it lives in slices a remote injects at runtime.
Next: client state across the seam. The remotes stop borrowing the host’s store and start adding to it, injecting their own reducers and dispatching actions other modules react to.
Sources
- Redux Toolkit: code splitting —
injectEndpointsand adding endpoints to an existing API at runtime - RTK Query — the cache, the tags, and the generated hooks
- Zod — the schema library guarding the runtime boundary
- PokéAPI — the free REST API the list is fetched from
- react-native-module-federation — the companion repo, at the tag
post-06-shared-store