DEV Community

Cover image for The contract package: a versioned seam between federated remotes in React Native
Warren de Leon
Warren de Leon

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

The contract package: a versioned seam between federated remotes in React Native

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

Post 4 ended on a promise: both tabs would want to open the same Pokémon detail screen, and separately built apps would have to agree on what to pass it.

This post builds that, and it takes three changes that need each other. Each remote stops exposing a bare screen and exposes a whole navigation stack, so pushing a detail belongs to the tab that pushed it. The detail screen itself ships as a versioned package both tabs install — not as another deployable, for a reason worth spelling out. And the agreement about what crosses into that screen becomes a package too: a contract with a version number, which is where the real lesson of this post lives. The closing act breaks the agreement on purpose, three rungs up a version ladder, and ends at a hole no compiler in the codebase can see.

Carry on from your own post 4 code if you built along. Otherwise:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-04-host-shell
Enter fullscreen mode Exit fullscreen mode

Each tab grows a stack

In post 4 each remote handed the host a bare screen and the host arranged them into tabs. A detail screen changes what "inside a tab" means: tapping a row should push a new screen while the tab bar stays put, and whoever owns that push owns navigation inside the tab. That is the remote's business, not the shell's. So each remote now exposes a stack.

apps/list/src/ListStack.tsx:

import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import PokemonDetailScreen from '@pokedex/detail';
import type { ListParamList } from './routes';
import PokedexScreen from './PokedexScreen';

const Stack = createNativeStackNavigator<ListParamList>();

export default function ListStack() {
  return (
    <Stack.Navigator screenOptions={{ headerShown: false }}>
      <Stack.Screen
        name="PokedexList"
        component={PokedexScreen}
        options={{ title: 'Pokédex' }}
      />
      <Stack.Screen
        name="PokemonDetail"
        component={PokemonDetailScreen}
        options={{ headerShown: true, title: '' }}
      />
    </Stack.Navigator>
  );
}
Enter fullscreen mode Exit fullscreen mode

That import PokemonDetailScreen from '@pokedex/detail' is the second change of the post, and we will come back to it — first the wiring around it. The remote's public surface changed: it exposes ./ListStack where it used to expose ./PokedexScreen, so its rspack config renames the entry in exposes, and the host's two lazy imports become listApp/ListStack and partyApp/PartyStack. The party app mirrors all of it with its own PartyStack.

The share map evolves too, in both directions of post 4's rule. The remotes now import @react-navigation/native, @react-navigation/native-stack and react-native-screens, so all three join the shared singleton maps on every side — the map is a record of what more than one party imports, and three more packages just crossed that line. Two of the new entries state version by hand:

'@react-navigation/native': {
  singleton: true,
  version: navPkg.version,
  requiredVersion: pkg.dependencies['@react-navigation/native'],
},
Enter fullscreen mode Exit fullscreen mode

Rspack normally reads the version out of the package it is sharing, but it cannot for a package resolved through an exports map, and React Navigation has one. Without the explicit version the bundler silently skips the provide: nothing lands in the share scope, and the app dies at launch on RUNTIME-006 (eager) or quietly bundles a duplicate (not eager). The navigation packages need it; react-native-screens doesn't. Checking for an exports field in a package's package.json tells you which treatment it needs.

Run it and the product change is visible: tap a row, the detail pushes inside the tab, and the tab bar stays on screen.

🎞️ Animated demo: watch it on warrendeleon.com

Where does the shared screen live?

Both stacks mount the same detail screen. In this series' product it is one screen; in a real app it is the sort of thing two teams genuinely share. Where does it go?

The federated answer suggests itself: a third remote, a detailApp on its own port, declared in both consumers' remotes maps. It works mechanically — Module Federation resolves nested remotes without complaint — and it keeps the screen updatable without touching either consumer. It is also the wrong cut. A federation boundary is a deployable, with a dev server, a deploy pipeline, and a failure mode all its own. Teams cut those boundaries along domains: one team, one domain, one remote, several screens inside it. A single screen never earns that overhead, and a codebase that hands one out per screen is on its way to shipping an app per screen.

A screen two domains share is a component, and shared components have had a delivery mechanism for decades: a package. So the detail screen ships as @pokedex/detail, published to a registry and installed by both tab apps, each of which mounts it in its own stack wherever it sees fit. The tabs stay the deployables. The screen is a dependency. That rule gets argued in full two posts from here: a boundary is a team's domain, not a screen.

packages/detail/src/PokemonDetailScreen.tsx, the parts that matter:

import type { DetailParams } from '@pokedex/contracts';

const POKEMON: Record<number, { name: string; types: string[] }> = {
  1: { name: 'Bulbasaur', types: ['Grass', 'Poison'] },
  4: { name: 'Charmander', types: ['Fire'] },
  7: { name: 'Squirtle', types: ['Water'] },
  25: { name: 'Pikachu', types: ['Electric'] },
  133: { name: 'Eevee', types: ['Normal'] },
};

export interface PokemonDetailScreenProps {
  route: { params: DetailParams };
}

export default function PokemonDetailScreen({ route }: PokemonDetailScreenProps) {
  const { id } = route.params;
  const pokemon = POKEMON[id];
  // renders the dex number, name, sprite and type chips
}
Enter fullscreen mode Exit fullscreen mode

Two things are deliberate here. The screen carries its own copy of the Pokémon data — the list app has one too, and the two have already drifted, because the list needs names and this screen needs types as well. Two copies of the same facts is a smell, and it is planted on purpose: live data deletes both in the next post. And the props are typed structurally — { route: { params: DetailParams } } — rather than by importing React Navigation's types. The screen is mounted by two stacks it has never seen, so it states the shape it needs and stays free of a navigation dependency. Its DetailParams import is the contract package, which this post now needs to explain.

The agreement no app can own

Look at what has to line up. The list app pushes PokemonDetail with { id }. The party app will push the same route once it has members to tap. The screen reads route.params.id. Three codebases touch one shape, and none of them can define it for the others: a definition in the list app is invisible to the screen's package, and hand-copying the type into each codebase is drift with a delay on it.

The same problem exists one level up, where the host consumes the remotes. The host's ambient declarations describe modules that have no file to point at:

declare module 'listApp/ListStack' {
  import type { ListStackModule } from '@pokedex/contracts';
  const ListStack: ListStackModule;
  export default ListStack;
}
Enter fullscreen mode Exit fullscreen mode

An ambient declaration is a hand-written promise. TypeScript believes it because there is nothing to check it against — the module it describes resolves at runtime, from a server, long after the compiler exits. Pointing the declaration at a type from an installed package does not make the promise checkable; it makes the promise shared, so the host and the remote at least describe the surface from one definition instead of two guesses. What it still cannot catch, honestly: if the remote renames its expose, the declaration goes stale and the compiler stays green. The check on that seam does not exist at build time. Keep that sentence; the end of this post is built on it.

So the agreements live in @pokedex/contracts, a package that holds types and nothing else:

// packages/contracts/src/params.ts
export interface DetailParams {
  id: number;
}

export type DetailParamList = {
  PokemonDetail: DetailParams;
};
Enter fullscreen mode Exit fullscreen mode

Each stack embeds the fragment in its own param list — type ListParamList = DetailParamList & { PokedexList: undefined } — the screen package reads DetailParams, and the host types its module declarations from the same place. Everything in the package is erased at build. Nothing from it reaches a bundle; it exists so that every tsc in the repo checks against one definition.

Publish both to a registry

A package needs a registry, and file: paths won't do: a path is a location on one machine, and the point of both packages is that separately built apps — in a real org, separately owned repos — install the same artefact by name and version. Verdaccio is an npm registry in a single process, good enough to make everything about the flow real:

npx verdaccio                                    # :4873, stays up
npm adduser --registry http://localhost:4873     # any username, password and email
Enter fullscreen mode Exit fullscreen mode

A one-line .npmrc at the repo root points the scope at it — @pokedex:registry=http://localhost:4873/ — and both packages publish:

( cd packages/contracts && npm install && npm run build && npm publish )
( cd packages/detail && npm install && npm run build && npm publish )
Enter fullscreen mode Exit fullscreen mode
+ @pokedex/contracts@1.0.0
+ @pokedex/detail@1.0.0
Enter fullscreen mode Exit fullscreen mode

The detail package declares its needs as peers rather than bundling them:

"peerDependencies": {
  "@pokedex/contracts": ">=1",
  "react": "*",
  "react-native": "*",
  "react-native-safe-area-context": ">=5"
}
Enter fullscreen mode Exit fullscreen mode

A peer range is a claim about compatibility, and >=1 is a loose one: it says this screen works with any contract from 1.0.0 up. Loose ranges buy consumers flexibility to upgrade at their own pace. What they cost is precision, and the end of this post shows the price.

All three apps install both packages — a real install, by version, from the registry:

npm install @pokedex/contracts@^1.0.0 @pokedex/detail@^1.0.0
Enter fullscreen mode Exit fullscreen mode

Every app unpacks its own copy into node_modules, holding the version it asked for. Four copies of the contract across the codebase sounds like the drift problem again, until you notice the difference: these copies have version numbers on them, and version numbers are what the rest of this post is about.

Now break it: the version ladder

The setup works. Both tabs mount the installed screen, the pushes type-check against the installed contract, and the simulator behaves. The interesting question is what happens when the packages move and the apps don't — because in a real org they won't, not in step. Three rungs.

1.1.0, an addition

The party will eventually pass a slot-instance id when a tapped Pokémon came from a party slot. Optional field, additive change:

export interface DetailParams {
  id: number;
  uid?: string;
}
Enter fullscreen mode Exit fullscreen mode

Bump to 1.1.0, build, publish. Then, in the list app, the part most explanations skip:

npm install
Enter fullscreen mode Exit fullscreen mode
up to date, audited 1188 packages in 926ms
Enter fullscreen mode Exit fullscreen mode

Nothing happens. The lockfile pins 1.0.0, and npm install honours the lockfile. The caret in package.json says what this app would accept; the lockfile decides what it gets. Taking the minor is a deliberate act:

npm update @pokedex/contracts
Enter fullscreen mode Exit fullscreen mode
changed 1 package, and audited 1188 packages in 868ms
Enter fullscreen mode Exit fullscreen mode

Now npm ls @pokedex/contracts shows the new resolution, including the screen package accepting it through its loose peer:

+-- @pokedex/contracts@1.1.0
`-- @pokedex/detail@1.0.0
  `-- @pokedex/contracts@1.1.0 deduped
Enter fullscreen mode Exit fullscreen mode

tsc passes with no source change anywhere: a consumer built against 1.0.0 never passes uid, and still satisfies the type. That is what makes an additive change safe to roll out unevenly.

2.0.0, a field that has to be there

The next change is the kind that breaks. The detail screen wants to show where it was opened from, so the param becomes required, and the screen learns to render it:

export interface DetailParams {
  id: number;
  uid?: string;
  source: 'pokedex' | 'party';
}
Enter fullscreen mode Exit fullscreen mode

A required field invalidates the old shape, so the contract goes to 2.0.0. The screen package follows it there: @pokedex/detail@2.0.0, built against the new contract, rendering a small origin line from route.params.source. Back in the list app, with both on the registry:

npm install
Enter fullscreen mode Exit fullscreen mode
up to date, audited 1188 packages in 886ms
Enter fullscreen mode Exit fullscreen mode

It stays on 1.1.0. The caret refuses the major without being asked, which is the one piece of this whole arrangement that works by default.

The blind spot

Three teams ship at three speeds. The party team adopts both 2.0.0s and compiles clean — it pushes nothing yet, so the new requirement costs it nothing. The list team has a release out and stays on the 1.1.0 contract. But the screen package is just a dependency, and dependencies get bumped: someone in the list team takes the new screen without taking the new contract.

npm install @pokedex/detail@^2.0.0
Enter fullscreen mode Exit fullscreen mode
changed 1 package, and audited 1188 packages in 1s
Enter fullscreen mode Exit fullscreen mode

No warning. The screen's peer range says >=1, the list app holds 1.1.0, and 1.1.0 >= 1. npm is satisfied:

+-- @pokedex/contracts@1.1.0
`-- @pokedex/detail@2.0.0
  `-- @pokedex/contracts@1.1.0 deduped
Enter fullscreen mode Exit fullscreen mode

Here is the part worth slowing down for. The list app now bundles a screen whose runtime code reads route.params.source. Ask what its compiler checked, and the answer is: the screen's published type declarations say the props take DetailParams — imported from @pokedex/contracts, resolved to whatever copy the consumer installed. The list app resolved it to 1.1.0, where source does not exist. So the list app's push of { id } type-checks perfectly against the very screen that is about to read a field it never sent. Every tsc in the codebase is green, and every one of them is checking a different sentence than the one the runtime will speak.

At runtime, the list tab pushes Bulbasaur:

The Pokémon detail screen reading 'Opened from the' with nothing after it, because the list app holds an older contract version and never passed the field the newer screen renders

"Opened from the ", and then nothing. No crash, no red box, no warning in any terminal. A sentence with a hole in it, shipped to a user.

The contract did not fail. It did what a compile-time contract can do, which is hold each app to the version that app installed. The loose peer did not fail either; it did exactly what >=1 says. The hole is between them: a type is a compile-time promise, and it is gone by the time a value actually crosses. Checking the value that arrives is a different job, done by different tools, at a different time.

What you built, and what's next

Navigation moved down into the remotes. The shared screen ships as a versioned package, because a federation boundary is a team's domain and a screen is a component. The agreements — params and module shapes — live in a contract package every side installs, and semver governs how the three codebases drift: minors roll out unevenly and harmlessly, majors wait for consent, and the one failure that slipped through did it silently, in the gap between a loose peer range and an erased type.

The finished code for this post is the post-05-contracts tag:

git checkout post-05-contracts
Enter fullscreen mode Exit fullscreen mode

Next in the series: real data. The hardcoded Pokémon lists disappear — both copies — and every screen reads from one store fed by PokéAPI. Which is where the hole in that sentence gets a proper answer, because a type that was checked at build time is no help against a value that shows up at runtime.

Sources

Top comments (0)