DEV Community

Cover image for The shared-singleton contract in React Native Module Federation
Warren de Leon
Warren de Leon

Posted on Edited on Originally published at warrendeleon.com

The shared-singleton contract in React Native Module Federation

📚 React Native Module Federation series — read it in full on warrendeleon.com, where new parts land first.

The last post ended by pointing here: the shared-singleton contract, and the mistake that crashes the app on launch. This post covers both. What shared actually means, the three options that control it, and the failure a remote hits when it breaks the contract on a library with a native side. That failure is loud, immediate, and names itself, which makes it one of the easier ones to fix.

We pick up exactly where post 2 left off. If you built along then, stay on your own code. If not, start from post 2's finished state:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-02-first-remote
Enter fullscreen mode Exit fullscreen mode

What "shared" actually meant in post 2

Post 2 declared react and react-native as shared singletons and moved on. Here is the host's half of that, from apps/host/rspack.config.mjs:

shared: {
  react: { singleton: true, eager: true, requiredVersion: pkg.dependencies.react },
  'react-native': {
    singleton: true,
    eager: true,
    requiredVersion: pkg.dependencies['react-native'],
  },
},
Enter fullscreen mode Exit fullscreen mode

Three options do the work, and each one answers a different question.

singleton: true answers "how many copies may exist at runtime?" One. When the host and the remote both ask for react, Module Federation hands them the same instance instead of letting each load its own. This is the load-bearing option. React keeps its hooks state in module-level variables, so two copies of React in one app means two separate piles of state, and any hook called against the wrong pile throws.

eager: true answers "is this copy ready before the app's first line runs?" On the host, yes. A normal Module Federation entry is asynchronous: it sets up the share scope, then starts your code. React Native does not give you that gap. Its entry is synchronous, so the host marks its shared copies eager to load them into the share scope up front, before AppRegistry renders anything. The remote does not need eager, because by the time it loads, the host has already filled the scope.

requiredVersion answers "which versions count as the same?" It pins the acceptable range. Stating it explicitly is a habit worth keeping, but leaving it out is not the same as switching the check off. The one case where the check really does disappear is the failure this post ends on.

So far this is post 2 with the reasoning filled in. The contract starts to matter the moment a remote depends on a third library, not just React.

A real dependency: the safe area

React Native's built-in SafeAreaView is deprecated. The maintained replacement is react-native-safe-area-context, and it ships with the current React Native template, so both apps in the repo already have it. It is a good test of the contract because it works through React context: a SafeAreaProvider mounted near the root measures the device's safe area, and any component below reads it with useSafeAreaInsets.

In a federated app, the provider and the consumer live in different bundles. The host owns the shell, so the host mounts the provider. Rewrite apps/host/App.tsx:

import React, { Suspense } from 'react';
import { ActivityIndicator, StyleSheet } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';

const PokedexScreen = React.lazy(() => import('listApp/PokedexScreen'));

export default function App() {
  return (
    <SafeAreaProvider>
      <Suspense fallback={<ActivityIndicator style={styles.loader} size="large" />}>
        <PokedexScreen />
      </Suspense>
    </SafeAreaProvider>
  );
}

const styles = StyleSheet.create({
  loader: { flex: 1 },
});
Enter fullscreen mode Exit fullscreen mode

The host no longer pads the screen itself. It provides the safe-area context and hands the whole canvas to the remote. Now the remote reads the inset and keeps its own title clear of the notch. Update apps/list/src/PokedexScreen.tsx:

import React from 'react';
import { FlatList, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

const POKEMON = [
  { id: 1, name: 'Bulbasaur' },
  { id: 4, name: 'Charmander' },
  { id: 7, name: 'Squirtle' },
  { id: 25, name: 'Pikachu' },
  { id: 133, name: 'Eevee' },
];

export default function PokedexScreen() {
  const insets = useSafeAreaInsets();
  return (
    <View style={[styles.screen, { paddingTop: insets.top + 24 }]}>
      <Text style={styles.title}>Pokédex</Text>
      <Text style={styles.subtitle}>Served by the list remote</Text>
      <FlatList
        data={POKEMON}
        keyExtractor={p => String(p.id)}
        renderItem={({ item }) => (
          <View style={styles.row}>
            <Text style={styles.number}>#{String(item.id).padStart(3, '0')}</Text>
            <Text style={styles.name}>{item.name}</Text>
          </View>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 24, backgroundColor: '#fff' },
  title: { fontSize: 28, fontWeight: '700' },
  subtitle: { fontSize: 14, color: '#6b7280', marginBottom: 16 },
  row: {
    flexDirection: 'row',
    paddingVertical: 12,
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: '#e5e7eb',
  },
  number: { width: 56, color: '#9ca3af', fontVariant: ['tabular-nums'] },
  name: { fontSize: 16, fontWeight: '500' },
});
Enter fullscreen mode Exit fullscreen mode

There is now a context handshake that crosses the bundle boundary: the provider is in the host, the useSafeAreaInsets call is in the remote. For that to connect, both apps need the same SafeAreaProvider, from the same copy of the library. A React context is identified by the object that creates it. Two copies of the library make two different context objects, and a consumer reading copy B will never see a provider mounted from copy A.

That is what the contract is for. Add the library to shared in both configs as a singleton. The host (apps/host/rspack.config.mjs), eager like its other shared copies:

shared: {
  react: { singleton: true, eager: true, requiredVersion: pkg.dependencies.react },
  'react-native': {
    singleton: true,
    eager: true,
    requiredVersion: pkg.dependencies['react-native'],
  },
  'react-native-safe-area-context': {
    singleton: true,
    eager: true,
    requiredVersion: pkg.dependencies['react-native-safe-area-context'],
  },
},
Enter fullscreen mode Exit fullscreen mode

And the remote (apps/list/rspack.config.mjs), singleton but not eager:

shared: {
  react: { singleton: true, requiredVersion: pkg.dependencies.react },
  'react-native': {
    singleton: true,
    requiredVersion: pkg.dependencies['react-native'],
  },
  'react-native-safe-area-context': {
    singleton: true,
    requiredVersion: pkg.dependencies['react-native-safe-area-context'],
  },
},
Enter fullscreen mode Exit fullscreen mode

Start both dev servers and run the host (the three-terminal routine from post 2). The Pokédex renders with its title sitting below the Dynamic Island, padded by the inset the remote read from the host's provider. One library, one provider, one context object, shared across two apps that were built and shipped on their own.

Now break it

Delete one entry. Take react-native-safe-area-context out of the remote's shared block, leaving it in the host's. This is the realistic version of the mistake: the host author shared it, the remote author forgot. Restart the remote's dev server and reload the host.

The app does not render a slightly-wrong screen. It red-boxes on launch:

Uncaught Error: Tried to register two views with the same name RNCSafeAreaProvider
Enter fullscreen mode Exit fullscreen mode

The app red-boxing on launch with the error: Tried to register two views with the same name RNCSafeAreaProvider

Here is why it is loud rather than quiet. react-native-safe-area-context is not pure JavaScript. It ships a native view, RNCSafeAreaProvider, that it registers with React Native's view registry at startup. The host's copy registers it once. When the remote drops the share, it bundles its own copy, and that copy tries to register the same native name a second time. React Native keeps one registry per app and refuses the duplicate. The crash fires before a single Pokémon reaches the screen.

📊 Diagram: view it on warrendeleon.com

This is the pattern for a library whose JavaScript registers something with the native layer, the way safe-area context registers its provider view: a navigation library, a gesture handler, anything that calls requireNativeComponent or keeps native-facing identity in module scope. Share it from one place and it works. Let two bundles each carry their own and both copies register the same native view, and they collide early and obviously. Not every native-backed library fails like this: a thin wrapper that only calls into a native module can survive duplication, because nothing identity-shaped collides. The ones that break register views or hold state, and the error even names the view, so the fix points back at the missing share.

Put that entry back in the remote's shared block, and the app builds and runs again.

React itself fails just as loudly, for a different reason. Drop react from a remote's shared and the remote bundles its own React. The first hook the remote runs gets checked against the wrong copy, and you get the familiar Invalid hook call red box. Same lesson: a library that keeps identity in module scope does not survive being loaded twice. That is a property of those libraries rather than a rule the runtime enforces everywhere.

The failure that does stay quiet

One case earns the "quiet" label, and it is requiredVersion, though not the way you might expect. Drop the field and, most of the time, nothing changes: Module Federation falls back to the version recorded for that dependency in the consuming package's own package.json, so a range still gets checked. The quiet failure needs one more ingredient: no version for the bundler to fall back on. That happens when the shared package is missing from the consumer's package.json, or when the package's layout stops the bundler reading a version for it (a trap the contract-package post walks straight into). With no range from either source, Module Federation has nothing to check: it loads whichever copy wins and runs on. That is the one to be careful with, because it builds and ships clean, and only shows up once two teams end up on different versions of a dependency. Pin requiredVersion explicitly, as the configs above do, and the check stops depending on what the bundler managed to infer.

So the general rule is the reassuring one. Most ways of breaking the shared contract announce themselves on launch. The quiet one is narrow, and a single field closes it.

What you built, and what's next

The host owns one SafeAreaProvider. The remote reads its insets across the bundle boundary, because both apps resolve to one shared copy of the library. You saw the contract hold, then watched it crash when a remote forgot its half, and you know now that the crash is the friendly outcome.

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-03-shared-singleton /tmp/pokedex-ref-03
cp -R /tmp/pokedex-ref-03/. .
Enter fullscreen mode Exit fullscreen mode

Next in the series: 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.

Sources

Top comments (0)