DEV Community

Cover image for The design system as a federated singleton in React Native
Warren de Leon
Warren de Leon

Posted on Originally published at warrendeleon.com

The design system as a federated singleton in React Native

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

Post 10 closed on a promise: the design system becomes a federated singleton, one UI package shared at runtime, so every remote renders the same components without shipping its own copy. This post keeps it.

The debt is real. Since post 4 every screen has styled itself with StyleSheet.create and hard-coded hexes, and the duplication now spans three apps and the view package: the same template greys sit in the list, the party and the detail view, and the same blue accent appears in the host's Refresh button and the detail's Add button. Four codebases, four copies of the same design decisions, and no way to change any of them in one place.

The fix applies two earlier patterns to pixels. Post 3 shared libraries as singletons so one copy serves every app. Posts 5 and 6 put packages at the seam so every side installs one definition.

@pokedex/ui does both. It packages gluestack-ui's copy-in components, styles them with NativeWind 4.2.6 and publishes them through the local Verdaccio registry. Module Federation then gives every remote the host's copy at runtime. This build uses @gluestack-ui/core on ^3.0.25, @gluestack-ui/utils on ^3.0.21 and Tailwind 3.4.19.

📊 Diagram: view it on warrendeleon.com

The seam now holds three packages, and they cross it differently. The contracts and the design system resolve to one copy at runtime, drawn as the host providing them. The detail package deliberately does not: each consumer keeps its own version pin, which has been its point since post 5. That contrast is the heart of this post.

A package for the pixels

Start from post 10's tag:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-10-two-backends
Enter fullscreen mode Exit fullscreen mode

One honest boundary before the anatomy. The design system's source is more than a post can carry: thirteen copied gluestack components, token maps for eighteen types, eleven composed components. So this post's packages are pulled from the finished tag rather than typed in, and that includes the new versions of contracts and detail, because this post grows both (The detail finishes dressing says how). Fetch the reference copy once (Run it reuses it for the native chrome) and take all three packages:

npx [email protected] --force warrendeleon/react-native-module-federation#post-11-design-system /tmp/pokedex-ref
rm -rf packages/ui packages/contracts packages/detail
cp -R /tmp/pokedex-ref/packages/ui /tmp/pokedex-ref/packages/contracts /tmp/pokedex-ref/packages/detail packages/
Enter fullscreen mode Exit fullscreen mode

packages/ui follows the anatomy posts 5 and 6 established: react-native-builder-bob builds an ESM lib/, "react-native": "./src/index.ts" ships raw source so Re.Pack compiles the same files the repo holds, publishConfig points at the local Verdaccio, and the files list adds two entries the earlier packages never needed: tailwind.preset.js and global.css.

The gluestack CLI copies component source into src/components/ui/. Copy-in means you own the source: your package publishes that code, and upstream updates do not arrive automatically. The companion keeps the thirteen primitives these screens use, from box to vstack.

One copy has a hidden dependency. heading imports @expo/html-elements, which the generated package does not declare. Add it to dependencies, or consumers fail on a package they did not install.

Above the primitives sit the tokens and composed components. tailwind.preset.js carries the brand and neutral colours plus type.<name> for all eighteen Pokémon types. The same values live in src/tokens/ for runtime code. Eleven components use them: ScreenContainer, PokemonCard, TypeBadge, LoadingState, ErrorState, InfoRow, StatBar, EmptySlot, BackPill, ThemeToggle and Toaster. A primitives.ts barrel keeps consumer imports on one package surface.

Two decisions belong with the tokens, and both fail quietly when they are left to the components.

Only literal class strings generate. A constructed `bg-type-${t}` is invisible to Tailwind's scanner, and the colour silently never ships. The token file spells out every class in literal maps (TYPE_BG_CLASS and friends) and exports lookup helpers, which keeps the whole palette in one auditable place.

The second is text contrast. Type colours need measured foregrounds, not a visual guess. White on the water blue measures 3.48:1 and on the psychic pink 3.41:1, below WCAG's 4.5:1 minimum for small text. Black clears 6:1 on both. textOnTypeClass() computes the higher-contrast choice once and every type-coloured surface uses it. A hard-coded black foreground would put the fighting-type hero at 1.7:1.

One kind of input could never have been typed in anyway: images. The theme toggle's moon and sun and the back pill's chevron ship as PNG files in src/assets, which came across with the copy, and the files list bundles them into the published package.

Publish it the way posts 5 and 6 did, install step and all:

( cd packages/ui && npm install && npm run build && npm publish )
Enter fullscreen mode Exit fullscreen mode

Version 1.0.2 is the intended 1.0.0 API plus the patches later hardening passes added.

The detail finishes dressing

The finished detail needs data the earlier contract did not carry. Contracts 3.2.2 adds heightM, weightKg, abilities, stats and optional flavourText to PokemonDetail. It also adds types to PartyMember, so the party can tint cards without another request. parsePokemonDetail converts PokéAPI's units and normalises the flavour text once at the boundary.

Both remotes now fetch the species payload beside the Pokémon, and the list passes type data when it adds a member. The app-file sweep in Wiring a styling runtime into a federated build carries those edits. The new package source already came from the reference copy, so publish contracts and detail before the apps install them:

( 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/detail 4.0.2 rebuilds the view around the design system: a type-coloured collapsing hero, a ringed sprite, flavour text, Info rows, Base Stats and the Add button. It is a major because it gains peers: @pokedex/ui, NativeWind and Reanimated become requirements on every consumer.

Its props do not change. The view still accepts { pokemon, loading, error, onRetry, onAddToParty, addDisabled, addLabel }, so both containers move to the new view with a version-pin edit. The largest visual change in the series reaches them through npm install.

The finished Pokémon detail screen: a grass-green hero with the sprite in a translucent ring over a ghost #001, Bulbasaur's name and type pills, then a flavour-text quote card, Info rows and Base Stats bars in floating cards

The collapse is the screen's signature move, and it is worth seeing before the wiring section, because every colour in it is a token class resolved through the seam:

🎞️ Animated demo: watch it on warrendeleon.com

Wiring a styling runtime into a federated build

NativeWind's own docs support Metro; Re.Pack appears under community-driven solutions. From the other side, Callstack ships a first-party plugin, @callstack/repack-plugin-nativewind, version-locked to the installed Re.Pack (5.2.5 here). Reanimated needs the same treatment: Re.Pack detects the package and warns when ReanimatedPlugin is missing, so @callstack/repack-plugin-reanimated goes in beside it at the same version. Both halves are true, and this wiring is the plugin's documented path.

All three apps receive the styling dependencies. The two remotes also install detail 4.0.2 and contracts 3.2.2. Every app declares gluestack core and utils directly because the copied source imports them. Each app also pins react and react-dom to its React version, 19.2.3, so peer resolution cannot install a second React.

( cd apps/host && npm pkg set overrides.react=19.2.3 overrides.react-dom=19.2.3 && npm install [email protected] @gluestack-ui/[email protected] @gluestack-ui/[email protected] [email protected] [email protected] @pokedex/[email protected] @pokedex/[email protected] && npm install -D [email protected] @callstack/[email protected] @callstack/[email protected] [email protected] [email protected] [email protected] @module-federation/[email protected] @react-native-community/[email protected] @react-native-community/[email protected] @react-native-community/[email protected] )
Enter fullscreen mode Exit fullscreen mode
( cd apps/list && npm pkg set overrides.react=19.2.3 overrides.react-dom=19.2.3 && npm install [email protected] @gluestack-ui/[email protected] @gluestack-ui/[email protected] [email protected] [email protected] @pokedex/[email protected] @pokedex/[email protected] @pokedex/[email protected] && npm install -D [email protected] @callstack/[email protected] @callstack/[email protected] [email protected] [email protected] [email protected] @module-federation/[email protected] @react-native-community/[email protected] @react-native-community/[email protected] @react-native-community/[email protected] )
Enter fullscreen mode Exit fullscreen mode
( cd apps/party && npm pkg set overrides.react=19.2.3 overrides.react-dom=19.2.3 && npm install [email protected] @gluestack-ui/[email protected] @gluestack-ui/[email protected] [email protected] [email protected] @pokedex/[email protected] @pokedex/[email protected] @pokedex/[email protected] && npm install -D [email protected] @callstack/[email protected] @callstack/[email protected] [email protected] [email protected] [email protected] @module-federation/[email protected] @react-native-community/[email protected] @react-native-community/[email protected] @react-native-community/[email protected] )
Enter fullscreen mode Exit fullscreen mode

The overrides block is why those installs start with npm pkg set. gluestack pulls react-native-web and react-dom in as transitive peers, and their react ranges collide with the app's pinned [email protected]. Pinning both in overrides settles it. Reaching for --legacy-peer-deps instead hides the version mismatches a shared singleton needs you to see.

The last four pins update @module-federation/enhanced and the React Native CLI trio (the core package and both platform packages) to their current releases, which clear this stack's fixable npm audit advisories; the repo README records what remains and why. react-native-reanimated and react-native-worklets carry native code, so the host needs its pods refreshed before the Run it build: ( cd apps/host/ios && bundle install && bundle exec pod install ), the same convention the repo README uses. Who shares what explains why react-native-reanimated and react-native-worklets are here at all. Pin tailwindcss below 4. NativeWind 4's styling runtime, react-native-css-interop, declares a Tailwind 3 dependency, and npm's latest Tailwind is a 4.x that will not work here.

The per-app configuration, entry files and rebuilt screens come from the reference copy. This sweep makes the reader's tree match the tag:

for app in host list party; do
  cp /tmp/pokedex-ref/apps/$app/babel.config.js \
     /tmp/pokedex-ref/apps/$app/global.css \
     /tmp/pokedex-ref/apps/$app/index.js \
     /tmp/pokedex-ref/apps/$app/jest.config.js \
     /tmp/pokedex-ref/apps/$app/rspack.config.mjs \
     /tmp/pokedex-ref/apps/$app/tailwind.config.js \
     /tmp/pokedex-ref/apps/$app/tsconfig.json apps/$app/
done
cp /tmp/pokedex-ref/apps/host/App.tsx /tmp/pokedex-ref/apps/host/mf-modules.d.ts apps/host/
cp /tmp/pokedex-ref/apps/host/__mocks__/react-native-reanimated.js /tmp/pokedex-ref/apps/host/__mocks__/partyApp-styles.js apps/host/__mocks__/
cp /tmp/pokedex-ref/apps/host/__tests__/RemoteBoundary.test.tsx apps/host/__tests__/
cp /tmp/pokedex-ref/apps/list/src/ListStack.tsx /tmp/pokedex-ref/apps/list/src/PokedexScreen.tsx /tmp/pokedex-ref/apps/list/src/detailApi.ts /tmp/pokedex-ref/apps/list/src/typesApi.ts apps/list/src/
mkdir -p apps/list/__mocks__
cp /tmp/pokedex-ref/apps/list/__mocks__/react-native-reanimated.js /tmp/pokedex-ref/apps/list/__mocks__/styleMock.js apps/list/__mocks__/
cp /tmp/pokedex-ref/apps/list/__tests__/addGate.test.tsx /tmp/pokedex-ref/apps/list/__tests__/doubleTapAdd.test.tsx /tmp/pokedex-ref/apps/list/__tests__/parsePokemonDetail.test.ts /tmp/pokedex-ref/apps/list/__tests__/parsePokemonTypes.test.ts apps/list/__tests__/
cp /tmp/pokedex-ref/apps/party/src/PartyScreen.tsx /tmp/pokedex-ref/apps/party/src/PartyStack.tsx /tmp/pokedex-ref/apps/party/src/detailApi.ts /tmp/pokedex-ref/apps/party/src/styles.ts apps/party/src/
cp /tmp/pokedex-ref/apps/party/__tests__/partyStateReady.test.ts /tmp/pokedex-ref/apps/party/__tests__/partySelfRecovery.test.tsx /tmp/pokedex-ref/apps/party/__tests__/removeAnimatesBeforeCommit.test.tsx apps/party/__tests__/
mkdir -p apps/party/__mocks__
cp /tmp/pokedex-ref/apps/party/__mocks__/react-native-reanimated.js apps/party/__mocks__/
cp -R /tmp/pokedex-ref/scripts .
cp /tmp/pokedex-ref/README.md .
Enter fullscreen mode Exit fullscreen mode

Babel gains NativeWind's JSX source, the Rspack config gains the plugin, and a three-line global.css holds the Tailwind directives. The interesting file is tailwind.config.js:

module.exports = {
  presets: [require('nativewind/preset'), require('@pokedex/ui/tailwind.preset.js')],
  darkMode: 'class',
  content: [
    './App.tsx',
    './src/**/*.{js,jsx,ts,tsx}',
    path.join(path.dirname(require.resolve('@pokedex/ui/package.json')), 'src/**/*.{js,jsx,ts,tsx}'),
    path.join(path.dirname(require.resolve('@pokedex/detail/package.json')), 'src/**/*.{js,jsx,ts,tsx}'),
  ],
  theme: { extend: {} },
  plugins: [],
};
Enter fullscreen mode Exit fullscreen mode

Three wiring warnings live in that file and its neighbours, and each one fails silently if ignored.

Scan the packages' source. Tailwind generates a class only when it sees the literal string, and the design system's classes live in node_modules. The content globs must include the shipped source of @pokedex/ui and @pokedex/detail, resolved via require.resolve. Miss this and the breakage looks intermittent: a class the app also uses still works, and only the classes unique to the library vanish.

Import global.css in every exposed module, not just the entry. Federation loads the exposed module, never the remote's own index.js, so a global.css imported only at the entry works standalone and silently no-ops federated. ListStack.tsx imports it at the top for exactly this reason, and the party reaches it through the module described in Styles are a boot-time registration.

darkMode: 'class' is load-bearing. NativeWind's setColorScheme throws without it, and the One toggle, three bundles flip is built on that call.

Styles are a boot-time registration

The party's copy of that import moved one step earlier, and the reason is worth the detour.

Registering a stylesheet writes into the styling runtime every mounted component reads. When the party tab's module arrives during a lazy render, that write lands while the Pokédex's screens are on screen, and React says so in development: Can't perform a React state update on a component that hasn't mounted yet, with a component stack pointing into the list's bundle. Three of them, on the tab's first open. Nothing breaks. The screen paints correctly, the warning is a development-only log, and a reader who never opens the second tab never sees it.

It is still worth fixing, because the cause is now specific rather than mysterious. Post 8 recorded a warning of the same shape at boot and said plainly that it had not been traced. This one traces: import '../global.css' at the top of an exposed module runs during the render that loads that module. Bisecting confirms it. Comment the import out and the tab opens silently.

So the party's styles became a module of their own:

// apps/party/src/styles.ts
// The remote's styles as a module the host can preload, for the same reason partySlice is one:
// registering them is a side effect the styling runtime shares with every app already mounted.
import '../global.css';
Enter fullscreen mode Exit fullscreen mode

It joins the exposes map beside the state module, and PartyStack.tsx imports ./styles rather than the stylesheet directly, so the standalone build still registers them:

exposes: {
  './PartyStack': './src/PartyStack.tsx',
  './partySlice': './src/partySlice.ts',
  './styles': './src/styles.ts',
},
Enter fullscreen mode Exit fullscreen mode

The host then loads it in the effect that already loads the state module, one line above it:

import('partyApp/styles').catch(err => console.warn('party styles failed to load', err));
Enter fullscreen mode Exit fullscreen mode

That extends post 8's rule by a second half. What a remote must register before it renders (its reducer, its styles) loads at boot; screens still load on demand. The Pokédex tab needs no such move for a reason worth stating: it is the first thing the host renders, so its styles register while there is nothing else mounted to disturb.

Who shares what

The shared map decision is the identity test from posts 3 and 6, asked three times with three different answers.

nativewind belongs in the shared map because its runtime holds a module-level style registry and colour-scheme state. Two copies let a remote register styles in a registry the host never reads. Re.Pack discussion #1032 records the same registry failure. Its accepted answer suggests deep-sharing react-native-css-interop/, later replies still report the problem, and PR #1242 closed without merging its accumulation approach.

The host provides the one stateful styling runtime. With NativeWind shared as an eager singleton and the Re.Pack plugin wired into each app, both remotes register styles in the registry the host reads. This build verifies that behaviour across both remotes and both colour schemes.

@pokedex/ui joins it as an eager host provider: one provider, one theme and one set of component instances. Build-time tools stay out of the runtime map. Tailwind and PostCSS never ship, while Zod carries values rather than process-wide identity. The four host entries are:

'@pokedex/ui': {
  singleton: true,
  eager: true,
  requiredVersion: pkg.dependencies['@pokedex/ui'],
},
nativewind: {
  singleton: true,
  eager: true,
  requiredVersion: pkg.dependencies.nativewind,
},
'react-native-reanimated': {
  singleton: true,
  eager: true,
  requiredVersion: pkg.dependencies['react-native-reanimated'],
},
'react-native-worklets': {
  singleton: true,
  eager: true,
  requiredVersion: pkg.dependencies['react-native-worklets'],
},
Enter fullscreen mode Exit fullscreen mode

Reanimated and Worklets register native views and a worklet runtime once per process. When the list bundled another copy for the detail collapse, the app died at load with the same native view name registered twice. The host now provides one copy to every consumer.

And @pokedex/detail stays out on purpose. Per-consumer versioning is its feature: the skew ladder from post 5 still works after this post, and a list shipping detail 4.0.2 while the party lags on 3.x is a supported state, not a bug. Same test, opposite answer, both correct.

The greys collapse

The screens keep their structure. The list renders PokemonCard in a three-column FlatList, and the party uses the same card beside the design system's EmptySlot. App-level colour literals move behind shared tokens; only token definitions and translucent detail overlays retain raw values:

Before, per app After, from the seam
#2a75bb in two apps' buttons bg-pokemonGreen, one token
#6b7280, #9ca3af, #f3f4f6 template greys text-darkGrey, text-midGrey, bg-offGrey
#eef2ff / #4338ca badge colours in the list TYPE_BG_CLASS per real type
#fff screens ScreenContainer variants: bg-offWhite light, bg-navy dark

Two smaller changes come with the conversion. The detail now arrives as a push inside each tab's stack, with the design system's BackPill floating over the hero, so the tab bar stays put while a detail is open:

🎞️ Animated demo: watch it on warrendeleon.com

And post 6's header Refresh button becomes a pull gesture the list owns. The dispatch is unchanged (one invalidated tag, which both of post 10's endpoints provide), so the REST rows and the GraphQL badges refetch together from a gesture instead of a button:

🎞️ Animated demo: watch it on warrendeleon.com

One toggle, three bundles

ThemeToggle is a design-system component with no props: a press writes the colour scheme into the shared nativewind singleton, and each remote mounts it in its own header's right slot. The interesting part is what it does not do: it never talks to a remote. The scheme is module-level state inside the shared styling runtime, every dark: class in the runtime re-resolves on the write, and three independently shipped bundles repaint together. No doc promises that; it is verified in this build.

The chrome changes on the same write. Everything derives from the same observable: the host themes its NavigationContainer from useColorScheme(), the same subscription the classes use. An earlier cut of this build kept a separate piece of host state beside it, and the native navigation bars flipped almost half a second before the content's styling pass. One source of truth put every surface on the same update wave.

🎞️ Animated demo: watch it on warrendeleon.com

Feedback rides the same singleton

Party feedback uses the same shared package. toast() is a plain function in @pokedex/ui, while the host mounts the runtime's one Toaster above the tab bar. After dispatching an add, the list reads the store and reports what the owner did. Two fast taps at five members produce one “joined your party” message and one “Your party is full” message because the reducer decides the outcome.

The list never mounts the component that draws the capsule. It calls the shared presentation API, and the host's Toaster renders the result with the Pokémon's sprite and type colour.

🎞️ Animated demo: watch it on warrendeleon.com

The party's remove confirms through the same path, which is the ownership rule from post 6 carried into presentation: each write's owner owns its feedback. toast() also announces the message to the screen reader itself, so a caller cannot show the visual confirmation and skip the accessible one.

🎞️ Animated demo: watch it on warrendeleon.com

Now break it

The map earned its entries; prove it by removing one. In apps/list/rspack.config.mjs, delete the nativewind entry from shared, restart the list's dev server, and reload.

Nothing crashes. The app boots, the list renders, the sprites load, and the layout is quietly wrong: the grid's column wrappers lose their widths, so each card sizes to its own content and the three columns stop lining up between rows. The colours hold, and so do the badges and the type, which is what makes this failure easy to miss. The remote now bundles its own styling runtime. Its build compiles the remote's classes into a registry the shared components never read, and the widths are the part that goes missing on screen. The components from @pokedex/ui are unaffected, because their classes were compiled by the host's scan into the host's runtime.

Post 6's failure was loud, post 8's was silent, and this one is silent and partial: the screen still works, and its grid stops being the design system's grid. Put the entry back, restart, and the grid snaps into shape. A styling runtime is shared state, and shared state under federation is either a singleton or a bug.

Run it

Native assets still ship with the host binary. Copy the tab icons, app icon, launch ball, Nunito fonts and native configuration from the companion. The ios/Host copy carries the icon catalogue, launch storyboard and app delegate. Copy project.pbxproj too, because Xcode renders the loose splash images only when the project lists them as resources. The final command links the typefaces:

cp -R /tmp/pokedex-ref/apps/host/src/assets apps/host/src/
cp -R /tmp/pokedex-ref/assets .
cp /tmp/pokedex-ref/apps/host/react-native.config.js apps/host/
cp /tmp/pokedex-ref/apps/list/react-native.config.js apps/list/
cp /tmp/pokedex-ref/apps/party/react-native.config.js apps/party/
cp -R /tmp/pokedex-ref/apps/host/ios/Host apps/host/ios/
cp /tmp/pokedex-ref/apps/host/ios/Host.xcodeproj/project.pbxproj apps/host/ios/Host.xcodeproj/
cp -R /tmp/pokedex-ref/apps/host/android/app/src/main/res apps/host/android/app/src/main/
( cd apps/host && npx [email protected] )
Enter fullscreen mode Exit fullscreen mode

Verdaccio is already running from the publish steps, and the three packages are published. Start the three dev servers, each from its own terminal:

( cd apps/list && npm run start:remote )
Enter fullscreen mode Exit fullscreen mode
( cd apps/party && npm run start:remote )
Enter fullscreen mode Exit fullscreen mode
( cd apps/host && npm start )
Enter fullscreen mode Exit fullscreen mode

Then build and launch the host app once, so the new app icon and asset catalogue compile in:

( cd apps/host && npm run ios )
Enter fullscreen mode Exit fullscreen mode

Open the Pokédex tab for the three-column card grid, tap a Pokémon for the finished detail, add it to the party and watch the confirmation rise, then flip the header toggle to watch every surface, both remotes and the chrome included, change scheme on one press. The whole loop, splash to empty party, looks like this:

🎞️ Animated demo: watch it on warrendeleon.com

What you built, and what's next

One design system and one styling runtime now serve every remote. The remotes import @pokedex/ui as a package, while federation resolves it to the host's copy. A shell release can re-skin them without rebuilding each remote, provided the component API remains compatible.

The native boundary remains. react-native-svg ships a native module, and native code cannot arrive through federation. Fonts reach the same boundary for a different reason: the binary must contain and register each typeface at build time.

The companion makes both cases visible. The tab icons are plain tinted images, the Nunito faces are linked into the host binary, and the launch ball is a storyboard asset.

Next: accessibility across the seam, a shared testing package that checks touch targets, contrast and focus order in remotes the host only meets at runtime.

Sources

Top comments (0)