📚 React Native Module Federation series — read it in full on warrendeleon.com, where new parts land first.
Post 3 closed on a promise: the host stops being a single screen and becomes a real shell, owning the tab bar while each tab is a remote loaded at runtime. That's this post.
So far the host has loaded one screen from one remote, fullscreen, with nowhere else to go. A real app has a frame around its features: a tab bar, navigation, the parts that are always on screen. This post gives the host that job, and gives the second tab to a second remote.
The division of labour
One sentence carries the whole post: the host owns the frame, and remotes own the features inside it.
The host installs the navigation library, mounts the tab bar and decides which tabs exist. The remotes stay plain screens. They don't import a navigator, don't know they're in a tab, and don't know a second remote exists. Adding a feature should be boring: build another remote, add another tab, ship it.
Two teams can then own two tabs, build them separately and deploy them on their own schedules, because neither one has to compile against the other.
📊 Diagram: view it on warrendeleon.com
The starting point is post 3's finished state. If you built along, that's the code you already have. If not:
git clone https://github.com/warrendeleon/react-native-module-federation
git checkout post-03-shared-singleton
A second remote to fill a second tab
One tab is not a tab bar. So we add a second remote, party, the same way post 2 built the list remote: a fresh React Native app on Re.Pack, with no AppRegistry.registerComponent, exposing one screen. Create it next to the others, install its dependencies exactly as you did for list, and copy list's rspack.config.mjs across.
Four fields change, and all four matter: the plugin name (partyApp), the container filename (partyApp.container.js.bundle), the exposed screen (./PartyScreen), and output.uniqueName ('PartyApp'). That last one is the easy one to miss. uniqueName scopes webpack's chunk-loading globals, so two remotes shipping the same value collide inside the host's runtime.
The screen it exposes, apps/party/src/PartyScreen.tsx, holds six empty slots:
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const SLOTS = [1, 2, 3, 4, 5, 6];
export default function PartyScreen() {
const insets = useSafeAreaInsets();
return (
<View style={[styles.screen, { paddingTop: insets.top + 24 }]}>
<Text style={styles.title}>Party</Text>
<Text style={styles.subtitle}>
Your party is empty. Add up to 6 Pokémon from the Pokédex.
</Text>
<View style={styles.grid}>
{SLOTS.map(slot => (
<View key={slot} style={styles.slot}>
<Text style={styles.slotNumber}>{slot}</Text>
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 24, backgroundColor: '#fff' },
title: { fontSize: 28, fontWeight: '700' },
subtitle: { fontSize: 14, color: '#6b7280', marginBottom: 24 },
// alignItems: 'flex-start' stops the default stretch from overriding each slot's aspectRatio.
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, alignItems: 'flex-start' },
slot: {
width: '47%',
aspectRatio: 1,
borderRadius: 12,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: '#e5e7eb',
backgroundColor: '#f9fafb',
alignItems: 'center',
justifyContent: 'center',
},
slotNumber: { fontSize: 20, fontWeight: '600', color: '#9ca3af' },
});
Nothing fills those slots yet, and that's deliberate. The app has no store, so the party has nowhere to keep its members; that arrives with the state posts. Today the team owning this feature ships the smallest thing it can put in front of a user: a screen with a shape and no data behind it. A screen with no props and no state is still a feature the shell can load at runtime.
It reads the safe-area inset through useSafeAreaInsets, which is post 3's shared singleton earning its keep: the inset comes from the host's provider, measured once, and this remote reads it without carrying its own copy of the library.
Give it its own dev-server port so it doesn't collide with list on 8082. In apps/party/package.json:
"scripts": {
"start:remote": "react-native start --config rspack.config.mjs --port 8083"
}
Now there are two remotes, on 8082 and 8083, each a screen waiting for a host.
The host gets navigation
The tab bar belongs to the host, not the remotes. Install the navigation packages in the host only:
cd apps/host
npm install @react-navigation/native @react-navigation/bottom-tabs react-native-screens
cd apps/host/ios && bundle exec pod install
react-native-screens ships native code, so that pod install isn't optional and neither is a fresh native build afterwards. Skip it and the build stops with 'RNSViewInteractionAware.h' file not found, which reads like a broken library and is really a missing pod. It is the first native dependency the shell has taken on since post 2, and a preview of a constraint the series returns to later: JavaScript can arrive over the wire, native code cannot.
The federation part is post 3's contract read backwards. The shared singletons stay exactly what they were: react, react-native, and react-native-safe-area-context. React Navigation and react-native-screens are not shared, because no remote imports them.
Sharing everything by default is a defensible habit: one fewer decision per dependency, and no chance of forgetting something you needed. But sharing keeps two copies of a library from both loading in one runtime, and navigation never leaves the host, so there's no second copy to collide with.
The rule runs both ways: share a library when code on both sides touches it, and only then.
The shell
Rewrite apps/host/App.tsx. The host now owns a SafeAreaProvider, a NavigationContainer and a bottom tab navigator, and each tab's content is a lazily-loaded remote:
import React, { Suspense } from 'react';
import { ActivityIndicator, StyleSheet } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
const PokedexScreen = React.lazy(() => import('listApp/PokedexScreen'));
const PartyScreen = React.lazy(() => import('partyApp/PartyScreen'));
// A remote downloads the first time its tab is opened, so each tab renders behind a Suspense
// spinner. Wrapping once here keeps the lazy boundary out of the remotes.
function withSuspense(Remote: React.ComponentType) {
return function Tab() {
return (
<Suspense fallback={<ActivityIndicator style={styles.loader} size="large" />}>
<Remote />
</Suspense>
);
};
}
const PokedexTab = withSuspense(PokedexScreen);
const PartyTab = withSuspense(PartyScreen);
const Tab = createBottomTabNavigator();
export default function App() {
return (
<SafeAreaProvider>
<NavigationContainer>
<Tab.Navigator screenOptions={{ headerShown: false }}>
<Tab.Screen name="Pokédex" component={PokedexTab} />
<Tab.Screen name="Party" component={PartyTab} />
</Tab.Navigator>
</NavigationContainer>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
loader: { flex: 1 },
});
Each tab is a remote behind React.lazy and Suspense, so the app fetches only the tab it opens on and leaves the other until you press it.
The host needs to know where the second remote lives. Add it to remotes in apps/host/rspack.config.mjs:
remotes: {
listApp: `listApp@http://localhost:8082/${platform}/mf-manifest.json`,
partyApp: `partyApp@http://localhost:8083/${platform}/mf-manifest.json`,
},
And tell TypeScript the shape of the new federated import, in apps/host/mf-modules.d.ts:
declare module 'partyApp/PartyScreen' {
import type React from 'react';
const PartyScreen: React.ComponentType;
export default PartyScreen;
}
One more file, or the tests break before they run. React Navigation and react-native-screens ship ES modules, and the base React Native jest preset only sends React Native's own packages through Babel: its transformIgnorePatterns lists react-native, @react-native and @react-native-community, and nothing else. In apps/host/jest.config.js:
module.exports = {
preset: '@react-native/jest-preset',
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?|@react-navigation|react-native-screens)/)',
],
};
Run it
Four terminals now, one per remote, one for the host, plus the 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 host boots on the Pokédex tab and renders the list remote. Press Party and watch the 8083 terminal: its first request arrives at that moment, not at launch. The host fetches the container, runs it, and the empty grid appears.
🎞️ Animated demo: watch it on warrendeleon.com
Two features, built and served by two separate apps, sitting in one tab bar that belongs to neither of them.
Now break it
The host and the remote agree on one name in two separate files, and nothing checks that they still match. Break the agreement on purpose. In apps/party/rspack.config.mjs, rename the container:
new Repack.plugins.ModuleFederationPluginV2({
name: 'partyRemote', // the host still asks for partyApp
filename: 'partyApp.container.js.bundle',
Restart the party dev server. An rspack config change doesn't hot-reload, so a plain refresh keeps serving the old manifest and hides the fault. Then relaunch the app and press Party:
[ Federation Runtime ]: Unhandled error. ([ScriptManager] Failed while resolving script locator:)
while loading "./PartyScreen" from webpack/container/reference/partyApp
args: [ { scriptId: 'partyRemote', caller: undefined } ]
originalError: [Error: No resolver was able to resolve script partyRemote]
Read it backwards and the chain is clear. The host asked for partyApp, because that's the key in its remotes map. It fetched the manifest at 8083, and the manifest declared a container called partyRemote. The runtime then looked for a script by that name, and the host has no partyRemote registered, so its resolver has nothing to match. Nothing was missing and nothing was offline. The rename was applied in one of the two files that have to agree, not both.
Notice where the failure surfaces. You get a red box, not the spinner: an uncaught error rather than a tab that waits forever. Suspense handles a promise that hasn't settled yet; it doesn't catch one that rejects. Nothing in this app watches for that, which is fine on a laptop with two dev servers and much less fine when the remote comes off a CDN (a content delivery network). Catching it properly needs an error boundary, and that comes later in the series.
Put the name back, restart the server, press Party, and the grid returns.
What you built, and what's next
The host is a shell now. It owns navigation and the tab bar; each tab is a remote that was built and deployed on its own and loaded at runtime. The remotes stay simple: they render a screen and know nothing about how they are arranged. A third feature is a third remote and a third tab, with no change to the ones already shipped.
The finished code for this post is the post-04-host-shell tag, so you can diff it against your own:
git checkout post-04-host-shell
Next in the series: both tabs will want to open the same Pokémon detail screen. A screen two teams share is a component, not another deployable — and the first thing two independently built apps have to agree on is what to pass it. Neither side can own that agreement, so it becomes a package: a contract, published and installed by version.
Sources
- React Navigation — the bottom tab navigator the host shell is built on
-
Module Federation 2.0 — the
name@urlremotes that load each tab at runtime - react-native-screens — the native dependency React Navigation builds on
-
react-native-module-federation — the companion repo, at the tag
post-04-host-shell
Top comments (0)