📚 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
cd 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. The exact route, mirroring post 2's list steps:
npx @react-native-community/cli@20.1.0 init Party --directory apps/party --version 0.85.3
( cd apps/party && npm install -D @callstack/repack@5.2.5 @rspack/core@2.0.5 @module-federation/enhanced@2.5.0 @swc/helpers@0.5.23 @react-native-community/cli@20.2.0 @react-native-community/cli-platform-android@20.2.0 @react-native-community/cli-platform-ios@20.2.0 )
cp apps/list/react-native.config.js apps/party/
cp apps/list/rspack.config.mjs apps/party/
Then give it the same near-empty container entry the list has: copy apps/list/src/index.js to apps/party/src/index.js. The root index.js keeps its registration for standalone runs; the federated entry is the one with nothing to do at startup.
Four fields change in the copied apps/party/rspack.config.mjs, and all four matter. Their finished values:
// output, near the top:
uniqueName: 'PartyApp',
// inside the ModuleFederationPluginV2 options:
name: 'partyApp',
filename: 'partyApp.container.js.bundle',
exposes: {
'./PartyScreen': './src/PartyScreen.tsx',
},
uniqueNameis the easy one to miss.uniqueNamescopes 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
In this build 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@7.3.1 @react-navigation/bottom-tabs@7.18.0 react-native-screens@4.25.2 )
( cd apps/host/ios && bundle exec pod install )
That pod install is not optional, and neither is a fresh native build afterwards.
react-native-screensships native code. Skip either step 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.
Both sides touching a library is what makes sharing worth considering, not what makes it necessary. What decides it is whether the library carries identity: module-level state, a React context, a native module registered once per process. Post 3 is where that test came from, and post 7 is where it turns into a rule about who owns which boundary. Those have to be one copy. A value-only library that both sides import, with no instance to disagree about, is safe to duplicate and often cheaper to.
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 around the lazy tab. The series does not give that its own post, so treat it as work this shell still owes: the fallback and recovery story arrives with CDN delivery, where a missing remote stops being a laptop problem.
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.
Land exactly on this post's finished state. The walkthrough prints the load-bearing files; manifests, configs and the smaller edits live in the tag. To finish with a tree byte-equal to the tag, sweep the reference copy over yours. A file you typed correctly is overwritten with itself, and the sweep fills in what the prose did not print:
npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-04-host-shell /tmp/pokedex-ref-04
cp -R /tmp/pokedex-ref-04/. .
The finished code for this post is the post-04-host-shell tag, if you cloned rather than building along:
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. 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)