📚 React Native Module Federation series — read it in full on warrendeleon.com, where new parts land first.
The last post ended with a sentence with a hole in it: a screen rendering "Opened from the " and then nothing, because a type checked at build time is no help against a value that only shows up at runtime. It closed on a promise, too: real data, one store, shared across the remotes. This post keeps the promise, and the hole gets its proper answer along the way.
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, and the Pokédex domain filling it with both its endpoints, while the installed @pokedex/detail view renders what it is fed. Live PokéAPI data replaces every hardcoded Pokémon in the app, both drifted copies included.
The shape we're building, before any code:
📊 Diagram: view it on warrendeleon.com
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.
The host looks like the natural home for it. The host owns the store, the store owns the cache, and an API object next to the store it feeds is where a single app would put it. In a single app that instinct is right. Under federation it fails on identity: a consumer can only add endpoints to the same baseApi instance the host store wired in, and an instance that lives inside the host's source is one nothing else can import. The contract package is the one module every side already resolves to a single copy of, because it is a Module Federation singleton. Put the instance there and a consumer'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 feature, including features 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;
}
export interface PokemonDetail {
id: number;
name: string;
spriteUri: string;
types: string[];
}
const PokemonListResponseSchema = z.object({
results: z.array(z.object({ name: z.string(), url: z.string() })),
});
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+)\/?$/);
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) };
});
}
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 with no endpoints builds an empty shell: a reducer, some middleware, and an injectEndpoints method the consumers 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.
The two parse functions are the part post 5's broken sentence pointed at. 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 each raw response is validated with a Zod schema right at the seam, and a bad shape becomes a value the screen can handle instead of a crash. The detail schema keeps only what the detail screen renders; the full PokéAPI payload for one Pokémon is close to 300 KB of JSON, and parsing fields nothing displays would just be a bigger surface to break on. Runtime validation gets a post of its own later in the series.
A runtime export and two new peer dependencies are a breaking change, so the version takes a major bump. The number itself needs a glance at your own registry first: post 5's ladder already published 1.1.0 and 2.0.0, and a published number is spent for good, because the registry refuses to reuse it and every install relies on that refusal. So the runtime seam ships as 3.0.0. packages/contracts/package.json:
{
"name": "@pokedex/contracts",
"version": "3.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 apps install them, and the contract uses their copies rather than bundling its own. Install the package's dev dependencies and publish:
cd packages/contracts
npm install
npm publish
+ @pokedex/contracts@3.0.0
The caret does here what post 5 showed it doing. Every consumer sits on ^1.0.0, so npm install leaves all three apps exactly where they are even with 3.0.0 on the registry. Picking up the new contract is a deliberate move, one app at a time, and one of the three stays put on purpose.
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';
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;
The split of ownership is the point of the file. The store lives in the host: one store per app is the shell's job, like the tab bar. The API instance the store is built around lives in the contract package, for the identity reason above. The host owns the wiring; the seam owns the instance.
baseApi.middleware is load-bearing. It runs the cache lifecycle: fetching, deduplication, tag invalidation, cache eviction. Leave it off the store and the first screen to run a query hook throws a full-screen 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!
Worth pausing on the component stack in that capture: the crash site is PokedexScreen inside __federation_expose_ListStack.chunk.bundle. A remote's screen, in a chunk the host downloaded at runtime, hits an error caused by one missing line in the host's own store file. The failure crosses the module boundary in the opposite direction to everything else in this series. A loud failure, at least. Keep it in mind, because the sabotage near the end of this post gets no error at all.
The store goes in over the whole tree, so everything 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 render inside this tree and reach the store 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 anything outside the host to inject into the shared baseApi, three packages have to resolve to one copy at runtime: @reduxjs/toolkit, react-redux, and @pokedex/contracts itself. They join the host's shared map as eager singletons, and post 5's hard-won lesson applies to two of them straight away. apps/host/rspack.config.mjs, the additions:
import rtkPkg from '@reduxjs/toolkit/package.json' with { type: 'json' };
import reactReduxPkg from 'react-redux/package.json' with { type: 'json' };
// ...in the shared map:
'@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'],
},
Both Redux packages state version by hand, and @pokedex/contracts does not. Post 5 found the rule: rspack reads the version out of the package it is sharing, except for a package resolved through an exports map, where it silently skips the provide instead. @reduxjs/toolkit and react-redux both carry an exports map, so without the explicit version neither would land in the share scope. The contract package has no exports map, so it needs nothing. The bundle-grep from post 5 confirms all three provides register, contract included:
"@pokedex/contracts", version: "3.0.0"
"@reduxjs/toolkit", version: "2.12.0"
"react-redux", version: "9.3.0"
The list remote mirrors the same three entries without eager: the host provides the copies, the remote consumes them. This is the first time @pokedex/contracts appears in any 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.
Two of the apps add the packages and bump the contract:
( cd apps/host && npm install @reduxjs/toolkit@^2.12.0 react-redux@^9.3.0 @pokedex/contracts@^3.0.0 )
( cd apps/list && npm install @reduxjs/toolkit@^2.12.0 react-redux@^9.3.0 @pokedex/contracts@^3.0.0 )
The party app is the third, and it gets nothing. No Redux packages, no new shared entries, and both of its installed packages stay where they are: the contract on ^1.0.0 — two majors behind the seam it types against — and the detail screen on ^1.0.0, the static version. That is safe for a precise reason: the party app consumes only types from the contract, and types are erased at build. A runtime major changes what ships in the package's JavaScript; a consumer that never imports any of that JavaScript has nothing to break. Remotes opt into shared state; the shell forces nothing on them.
The list remote: real data
Now the first injection. apps/list/src/listApi.ts, a new file:
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. The shell knew nothing about this endpoint when it was built; the remote adds it to the running store the first time its code loads.
The screen drops its hardcoded five-row array and reads the 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>
)}
/>
);
}
Three states instead of one: a spinner while the request is in flight, an error screen with a retry when PokéAPI is unreachable, and the list. The navigation from post 5 is untouched — tapping a row still pushes PokemonDetail with { id } inside this remote's own stack. What changed is where the rows come from: a cache in the host's store, filled by an endpoint this remote injected, read through a hook that did not exist when the host shipped.
The detail goes live; the library stays a view
Post 5 left a deliberate smell in the detail package: its own private copy of the Pokémon data, drifted from the list's copy, with a comment promising that live data would delete it. This is that moment, and it takes two releases that draw one line.
The line: a component library ships pixels, and data definitions belong to the domain that owns the data. The next post argues that line in full; this one applies it. Pokémon data is the Pokédex domain's, so the second endpoint lands next to the first, in the list app. 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;
A query that takes the id as its argument, fetches one Pokémon, parses it at the seam. No tags: nothing invalidates a single Pokémon yet.
@pokedex/detail goes to 3.0.0 — a major, because the props change shape entirely. The static copy and its lookup are gone; what remains is a view: PokemonDetailView takes the Pokémon and the three states as props, renders them, and does nothing else. The list app composes hook and view in a small container on its detail route:
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} />;
}
Where the data comes from is the app's business; what it looks like is the library's. Nothing in the package touches the store, and going live added no dependencies to it at all: the same four peers the static version had, Redux nowhere among them.
Count the cost of going live: one endpoint file in the domain that owns the data, one honest major on the view, and an eight-line container. And the quieter payoff: the list's copy of the data and the package's copy have both gone, so the drift between them has gone too. Both screens now render whatever PokéAPI says, through one cache, and disagreeing with each other is no longer something they know how to do.
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 configs that carry it — host and list — leaving @reduxjs/toolkit and react-redux in place. Restart the dev servers and relaunch.
It spins. Forever. No crash, no red box, and this time nothing in the console either. Twenty seconds in, the only line the dev server has logged is the app starting up. Watch it for as long as you like: nothing else arrives.
The middleware warning from the store section would seem the natural failure here, 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 list 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 missing, nothing to warn about. Both of the domain's endpoints registered in the list's copy, one no store ever wired, so the endpoints exist, the hooks run, and the fetches they should trigger go 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, and it now has a full ladder. Two Reacts crash on launch. A missing middleware throws a red box that names the file to fix. Two baseApis give you a spinner over a cache that never fills, and the only diagnostic is the absence of everything else. A loud failure is cheap; the silent one costs the afternoon. Put the shared entries back, restart, and the list fills again.
Invalidate across the seam
The tag graph has been sitting unused since the contract declared it. Time to pull it.
Post 4 hid every header with headerShown: false on the tab navigator, and post 5 gave each remote's stack its own headers inside the tab. The host now turns the Pokédex tab's header back on, because it is about to put something host-owned in it. 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 />,
})}
/>
One detail in there deserves a closer look. The detail route brings its own stack header with a back button, and a tab header stacked on top of it would put two bars on screen. So the host hides its bar while the stack sits on the detail, and shows it the moment the stack pops back. Checking against 'PokemonDetail' looks like the host reaching into the remote's internal route names, and that is exactly what it would be if the name lived in the remote — but it doesn't. It is the route name from DetailParamList in @pokedex/contracts, the same agreement the params come from. The contract keeps paying for itself in places the last post never predicted.
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 with a subscriber on screen. The list refetches; the detail endpoint, which provides no tags, is untouched. One label, declared in the contract, provided by one feature, dispatched by the host, and the refetch lands in code the dispatcher has never seen.
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. Teams agree on tag names in the contract the way they agree on types, and that agreement is the entire refresh protocol between them.
Run it
The packages are published and installed, so Verdaccio isn't needed for the run. Three dev servers, one per app, then the simulator build:
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
The Pokédex tab shows a spinner for a moment, then fills with the first 151 Pokémon, official artwork included, straight from PokéAPI. Tap a row and the detail route fetches its Pokémon through the same cache. Tap Refresh in the header and the list reloads across the seam:
🎞️ Animated demo: watch it on warrendeleon.com
The party tab still renders its empty grid, contract two majors behind, detail screen still the static 1.0.0, store nowhere in sight, working exactly as it did before this post. Independence includes the freedom to not participate.
What you built, and what's next
The contract package owns the one baseApi every side injects into, so the cache, the deduplication, and the tag graph are shared across features that were built and shipped on their own. The host owns the store built around it. The Pokédex domain fills the cache with both its endpoints, the installed view renders what the containers feed it, the boundary is guarded with a schema, and the host refreshes data it cannot name by dispatching a tag. When the sharing is removed, the failure is a silent spinner; you have seen it once here, so you will know it when it is expensive.
Everything crossing the seam is still server state, though: data a server owns and the cache holds a copy of. Nothing the app itself owns has crossed a module boundary yet, and the app is starting to accumulate things it owns. The party grid is still empty because nothing you do in the Pokédex can reach it. Close the app and reopen it, and nothing you did survives: no party, no favourites, no memory. Client state is the other half of the split this post started from, and the build-along returns for it in two posts' time. First, the series steps back and argues the ownership calls the last three posts kept making one at a time: where a boundary goes, what a shared component may know, where a data definition lives.
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 app fetches from
-
react-native-module-federation — the companion repo, at the tag
post-06-shared-store

Top comments (0)