DEV Community

Cover image for The host shell: federated remotes as tabs in React Native
Warren de Leon
Warren de Leon

Posted on • Edited on • Originally published at warrendeleon.com

The host shell: federated remotes as tabs in React Native

📚 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
Enter fullscreen mode Exit fullscreen mode

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' },
});
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
cd apps/host/ios && bundle exec pod install
Enter fullscreen mode Exit fullscreen mode

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 },
});
Enter fullscreen mode Exit fullscreen mode

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`,
},
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)/)',
  ],
};
Enter fullscreen mode Exit fullscreen mode

Run it

Four terminals now, one per remote, one for the host, plus the build:

cd apps/list && npm run start:remote      # :8082
Enter fullscreen mode Exit fullscreen mode
cd apps/party && npm run start:remote     # :8083
Enter fullscreen mode Exit fullscreen mode
cd apps/host && npm start                 # :8081
Enter fullscreen mode Exit fullscreen mode
cd apps/host && npm run ios
Enter fullscreen mode Exit fullscreen mode

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',
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)