Subscribe

1/9: Scaling components: how to build a true cross platform unified library between React, React Native and Email

A guide into a truly multi platform ui components architecture

09/14/2026

user avatar
Does it scale & Technicium

Part 1 of a series. The rest: 2. The Brand That Finally Fit · 3. We Built the Fork, Proved It Worked, Then Watched It Fail in Production Anyway · 4. The Editor That Can't Come Home · 5. FormulaikMP: Why a Form Library Is Just a Resolver in a Trenchcoat · 6. Merging Two Auth Flows That Had Quietly Become Strangers · 7. MPNavigator: The Smallest Router That Could Possibly Work · 8. Injecting the Rest of the App In · 9. The Mirror-and-Shim Migration Pattern

We had a Button. Then we had three of them.

Not on purpose. web/main had a Button. app/main had a Button — same name, same rough intent, subtly different props, its own bugs, its own drift. templates, which renders transactional email out of React, had a Button too, because obviously <button onClick> doesn't survive contact with an email client. Three components, three teams of one maintaining them by accident, three places a design change had to land separately and usually didn't. Multiply that by every field type in a signup form, every stage of an account flow, and you get the actual motivation for @peakub/components: not "wouldn't it be nice to share code," but "we are already paying the cost of not sharing it, continuously, and it's invisible until someone asks why the mobile signup button looks like it's from 2019."

This is the story of building that library — the bet we made early, the parts that worked immediately, the one that didn't, and why the thing that replaced it is better than what we would have designed on paper.

The bet: primitives, not forks

The obvious way to share components across web, native, and email is file-extension polymorphism — button.web.js, button.native.js, button.email.js, resolved automatically by each platform's bundler. It's a real, well-worn pattern. It's also a trap if you reach for it by default: every component that could be shared but has even one platform-specific line ends up with three files, three implementations to keep behaviorally in sync, and the "shared library" quietly becomes three libraries wearing a trenchcoat.

We used the fork mechanism for exactly one thing: genuine, irreducible divergence. button.email.js is a real fork — email clients don't run JavaScript, so there's no onPress, no disabled state, no icons that survive Outlook's rendering engine. It's built on @react-email/components, not on anything web or native share. That's what a fork is for.

For everything else, we made a different bet: a small set of global primitives, defined once, that every component is written against instead of a real DOM element or a real React Native component.

// bootstrap/webGlobals.js
globalThis.MPView = forwardRef((props, ref) => {
  if (props.isSquircle && Squircle) {
    return <Squircle ref={ref} {...props}>{props.children}</Squircle>;
  }
  return <div ref={ref} {...props}>{props.children}</div>;
});
// bootstrap/nativeGlobals.js
globalThis.MPView = forwardRef(({ children, isSquircle, cornerRadius, ...rest }, ref) => (
  <View ref={ref} {...rest}>{children}</View>
));

A component written against MPView never knows or cares whether it's rendering a <div> or a React Native View. It's one file. It ships to both platforms. The platform difference lives in exactly one place — the bootstrap — instead of being smeared across every component that happens to need a box on the screen.

The primitive list stayed deliberately small: MPView, MPText, MPSpan, MPH1–MPH5, MPPressable, MPSpinner, MPTextInput, MPCheckbox, MPLink, MPFormulaik, and — later — MPNavigator. Everything in the library is built out of those, plus Tailwind class strings (which are just text; each platform's own styling pipeline, real Tailwind on web, NativeWind on native, decides what they mean).

Proving it: a Button that isn't lying about being shared

The easy failure mode for a component library is writing components that compile on every platform but that nobody actually uses — smoke tests standing in for real integration. So the rule we held ourselves to was: every primitive, every shared component, gets wired into a real call site in each consumer before we call it done. Not a Storybook story. A production screen.

That surfaced real work immediately. MPPressable — the primitive behind every clickable thing — had to support both onPress (the React Native convention) and onClick (the web one), because forcing one platform's naming onto the other's natural callers is exactly the kind of paper cut that makes a "shared" library feel foreign on one of its two homes:

globalThis.MPPressable = forwardRef(
  ({ onPress, onClick, disabled, isSquircle, cornerRadius = 18, testId, ...props }, ref) => {
    const handleClick = (e) => {
      e?.preventDefault?.();
      e?.stopPropagation?.();
      if (disabled) return;
      onPress?.(e);
      onClick?.(e);
    };
    // ...
  },
);

Both fire if both happen to be passed. Nobody has to remember which platform they're integrating into just to attach a click handler.

Wiring it for real also caught a bug that a smoke test never would have: a web/widgets card component whose click handlers had gone dead — MPPressable's squircle branch wasn't forwarding onClick at all. It had presumably been broken since whoever wrote it last touched it, silently, because nothing had ever actually clicked the thing in anger.

Forms without forking

Forms are where "just use primitives" gets tested hardest, because a form library isn't one component, it's a resolver: given a field's declared type, produce the component that renders it. We built MPFormulaik as a global resolving to @formulaik/react (web) or @formulaik/react-native (native) — same API, different engine — and a shared FormulaikMP component library sitting on top, one file per field type, each one just another consumer of MPTextInput/MPCheckbox/MPPressable.

The public documentation for the underlying form library describes its components prop as accepting a map — { input, submit, checkbox }. We tried that. Every field rendered nothing. No error, no warning, just an empty form.

The fix came from reading the library's actual compiled source, not its README:

// what the engine actually does internally
u({...item, type: a})

Each entry in components is called as a function, not looked up as a map key, and expected to return a component or null to fall through to the next library in the chain. So the resolver had to look like this:

const registry = { input, inputPassword, checkbox, submit };

export default function FormulaikMP(item) {
  return registry[item.type] || null;
}

Small fix, but it's the kind of bug that costs an afternoon if you trust documentation over the artifact that actually runs. It's also a preview of a pattern that shows up repeatedly in this story: the thing that looks like it should work silently doesn't, and the only way to know is to go look at what's actually executing.

The fourth implementation nobody meant to write

By the time three consumers (web/main, app/main, web/widgets) each had their own bootstrap calling setupAppGlobals-shaped functions, we noticed the library's own test setup and Storybook config had quietly become a fourth, independent, hand-rolled copy of the same primitive definitions — because nobody had thought to make the package eat its own dog food.

We collapsed all four into one: bootstrap/webGlobals.js and bootstrap/nativeGlobals.js, the single canonical definition of every MP* primitive, each accepting a small set of injectable dependencies (Squircle, Spinner, Formulaik, CheckIcon) so a consumer can supply its own real implementation without forking the file:

// web/main's own bootstrap
setupWebGlobals({ Squircle, Spinner: Loader2, Formulaik });
// web/widgets — same primitives, no Squircle (bundle-size-sensitive embed)
setupWebGlobals({ Spinner: Loader2 });

The library's own tests now call setupWebGlobals() with no overrides at all, exercising the exact same code path every real consumer does, falling back to plain DOM/RN defaults. No more testing against an approximation of the real thing.

The fold: two auth flows that had quietly become strangers

The largest single piece of work was domain/account — the multi-stage sign-in/sign-up/recover-password/delete-account flow. It existed, copy-pasted nearly verbatim, in both web/main and app/main. "Nearly" is doing a lot of work in that sentence: web/main's copy had grown a magic-link flow, a completeProfilePublications stage, passkey support; app/main's hadn't. Two implementations of the same product feature, diverging in production, invisibly, one merge at a time.

Worse than the duplication was a second abstraction layer sitting on top of it. Every stage component didn't reach for MPView/MPText/Button directly — it destructured a whole parallel vocabulary off an authPayload.components prop:

const {
  components: { Button, View, Text, Span, H1, H2, LinkView, LinkViewToStage, Formulaik },
} = authPayload;

That vocabulary was itself just a thin wrapper around the primitives we'd already built — duplicating, one indirection layer removed, work the library already did. Folding the account flow into one shared domain/account package meant deleting that whole layer: eighteen wrapper files across both platforms, gone, replaced by using MPView/Button/MPFormulaik directly, the same way everything else in the library already did.

Merging code that's diverged is also, unavoidably, an audit. We found real bugs that had nothing to do with the merge itself:

A "terms of use" link component that had been silently rendering empty for as long as anyone could tell — it read href and children props that its own implementation never actually consumed.

A native-only "complete your profile" stage reading stage.params.CustomPage, while the component that renders it had always read .Page. Dead on native since before this work started.

Two whole stage directories that existed on disk, fully written, and were never wired into the stage-id-to-component map at all.

None of that shows up in a diff review of "move file A to location B." It only shows up when you actually read what you're merging, closely enough to notice the pieces that don't fit — and then you fix them, rather than merge the bug forward faithfully.

Teaching the primitives to navigate

Account flows navigate — sign up, then verify your email, then complete your profile. On web that's next/router. On native it's a hand-rolled service wrapping React Navigation, resolving paths like /welcome/auth/signIn. Two different APIs, same underlying need: "go to this place."

The instinct is to build the One True Cross-Platform Router. We didn't, on purpose. MPNavigator is deliberately small:

MPNavigator.push(path, { params, web, native })
MPNavigator.replace(path, { params, web, native })
MPNavigator.back()
MPNavigator.getHref(path)
MPNavigator.goToAccountStage({ id, params })

path is the one thing both platforms already agree is real — a URL-shaped string. web and native are escape hatches, forwarded verbatim to that platform's own router, rather than lossily generalized into some smallest-common-denominator options object. When Next.js ships a new router option next year, nothing here has to change — it just gets passed through. Every default throws instead of silently no-op'ing, except getHref, where "no href available" is a legitimate answer (it always is, today, on native) rather than a bug.

Wiring the real implementation taught us one more lesson about being careful with when code runs, not just what it does. Native's navigation service touches AsyncStorage the moment it's imported — a real, working design choice in isolation, but our bootstrap was importing it eagerly at module load, which meant simply loading the test setup (before any test even rendered anything) crashed the entire suite, because AsyncStorage's native module doesn't exist under Jest. The fix was one word — require() the navigation service lazily, inside the callback that actually needs it, instead of at the top of the file:

goToStage: ({ id }) => {
  const NavigationService = require("../lib/services/navigation").default;
  NavigationService.navigate({ name: `/welcome/auth/${id}` });
},

Nothing about the feature changed. Everything about whether the test suite could run did.

The wrong turn

Eventually we hit the case the primitive convention doesn't obviously solve: web/main is genuinely, deeply invested in MUI — not for one component, for its entire local form-field library, including a real password-strength meter with composition rules and a strength score. app/main has nothing like it. @peakub/components had no MUI at all, on purpose. How does one consumer get its real, existing, working implementation without forcing a design system onto everyone else?

The idea we reached for first was per-consumer file forks, extended one level further than the usual .web.js/.native.js: input.web.main.js, resolved ahead of input.web.js/input.js by adding an extra entry to webpack's resolve.extensions, scoped to web/main's own next.config.js alone. Every other consumer's bundler had never heard of the extension and fell through to the plain default, untouched.

It worked. We proved it worked — not by assuming, but by hooking directly into webpack's own module resolution and watching it happen:

DEBUG afterResolve -> .../common/formulaik/input/input.web.main.js

Then we shipped it, and a genuinely unique string from the plain version showed up in the production bundle anyway, sitting right next to the MUI one. Not a caching artifact — we'd already ruled that out with a clean build. The real cause was smaller and much more dangerous: inputPassword.js, a thin wrapper around the input field, had its own separate import of the base component, written with an explicit .js extension. Webpack's resolve.extensions list only ever gets consulted for a request with no extension already on it — so that one import silently, permanently bypassed the fork, with no build error, no console warning, no test failure. It just quietly rendered the wrong component, in production, and the only reason we caught it was because we went looking for proof rather than trusting that a clean build meant a correct one.

That's not a bug you fix and move on from. It's a structural property of the mechanism: every internal reference to a forked file has to be extension-less, forever, for the fork to actually apply, and nothing catches the one you miss. A file-resolution graph has no single point where you can look and know the override is wired correctly. We'd built something that worked, and that could silently stop working the next time someone innocently typed .js at the end of an import.

What actually worked

The fix was to stop trying to make the file swappable and go back to what had already worked for Squircle and Spinner: make the primitive swappable.

MPTextInput and MPCheckbox already existed as globals. The only thing missing was room for a consumer's real implementation to receive the extra information a plain <input> has no use for — layout mode, a password-strength configuration, MUI's own styling hooks. We gave the primitive contract one deliberate escape hatch instead of a growing prop list:

// common/formulaik/input/input.js
const { placeholder, inputDelay = 500, multiline = false, testId, className = "", ...extraProps } = params;

<MPTextInput
  value={innerValue}
  onChangeText={handleChangeText}
  error={error}
  layoutMode={layoutMode}
  extraProps={extraProps}
  // ...
/>

The plain default destructures error/layoutMode/extraProps out and drops them — never spreads them onto a real DOM node, which would otherwise trigger React's "unrecognized attribute" warning — while quietly leaving them there for something else to read:

globalThis.MPTextInput = forwardRef(
  ({ value, onChangeText, error, layoutMode, extraProps, ...props }, ref) => {
    // plain default never looks at error/layoutMode/extraProps - just drops them
    return <input aria-invalid={!!error} value={value} onChange={handleChange} {...props} />;
  },
);

And web/main's bootstrap, right after installing the plain defaults, replaces two of them with the real thing:

export const setupPrimitiveOverrides = () => {
  global.MPTextInput = MPTextInputMui;
  global.MPCheckbox = MPCheckboxShadcn;
};

MPTextInputMui is a real MUI TextField, complete with the visibility toggle and strength meter, built from the exact same helper functions we'd already written for the abandoned fork — they didn't need MUI-specific knowledge of @peakub/components at all, so they simply moved to live where they belonged, in web/main's own codebase, instead of inside a shared package that has no business knowing MUI exists.

This has no resolution graph. There is exactly one place the assignment happens, and reading global.MPTextInput = MPTextInputMui tells you everything the file-fork version could only tell you after a build, a grep, and a webpack resolver hook wired in by hand to prove it. The class of bug that took an afternoon to track down structurally cannot happen here.

The rule, made explicit

We wrote the outcome down as a standing constraint, not a preference:

No platform-native concept may appear directly in @peakub/components's own source. Not <div>, not React Native's <View>, not MUI, not Radix, not any UI library, not a raw DOM or RN element. Only the MP* primitives, Formulaik, Tailwind class strings, and any future primitive defined the same way.

The test for whether the discipline is holding is simple: could you read any file in common/ or domain/ and know, just from the code, which design system is actually rendering at runtime? If the answer is yes, something has already gone wrong — the whole point is that the shared package never knows, and never needs to.

Wait, doesn't that contradict button.email.js?

The first time we said this rule out loud, someone immediately noticed the tension: we already have a real, sanctioned fork sitting in the codebase. button.email.js doesn't render through MPPressable at all — it's built directly on @react-email/components. Isn't that exactly the thing we just said not to do?

No, and working out why not turned out to matter more than the rule itself. The primitives convention isn't "never write platform-specific code." It's "never write platform-specific code where a primitive could have worked instead." Those are different claims, and the difference is the whole point.

Email has no runtime to install anything into. There's no bootstrap call, no JS execution, nothing that could ever assign global.MPView to something, because by the time an email client renders the output, @react-email/render has already turned it into static HTML and walked away. The override mechanism doesn't almost-work for email — it structurally cannot exist there. A fork isn't a shortcut in that situation, it's the only honest thing on offer.

We pressure-tested the boundary with a hypothetical: suppose app/main wants Button to render through react-native-paper instead of a plain Pressable. Is that a fork? It feels similar to the email case on the surface — a whole different rendering library, a whole different visual language. But it isn't the same kind of problem at all. Native can render a plain Pressable just fine without Paper. Nothing about the platform is stopping it. What's actually being asked for is a preference, not a capability — and we'd already built the exact tool for that: the same override seam that gave web/main its real MUI TextField without forking anything. app/main's bootstrap would reassign MPPressable (or, more precisely — see below) to a Paper-backed implementation, the same shape as global.MPTextInput = MPTextInputMui, and Button would render through it without ever knowing Paper exists.

The test we settled on: does the plain default genuinely fail to work here, or does it just look different from what one consumer wants? Fails to work → fork. Looks different → override. button.email.js fails the first test in the most literal way possible — there's no default that could work at all. react-native-paper fails the second test just as clearly — the plain default renders fine, someone just wants it to render as something else.

Chasing that hypothetical down also surfaced a real gap we hadn't hit yet: Button isn't actually a global today. It's a plain exported component built on top of MPPressable, imported the normal way. You can override MPPressable — but that changes every pressable-shaped thing in the app, not just Button, which is a bigger blast radius than "I wanted a different button." The honest fix, if that need ever becomes real, is promoting Button itself to a primitive — MPButton, installed by the bootstrap with today's implementation as the plain default, overridable exactly like MPTextInput is. We didn't do it speculatively. It's written down as a known gap instead, waiting for a real consumer to actually need it.

What we'd tell someone starting this today

Reach for global primitives before you reach for per-platform files. Reserve forking for divergence you can't paper over — email really is a different world, and pretending otherwise wastes more effort than the fork costs. When one consumer needs something the shared primitive can't express, don't fork the primitive's file — extend its contract, with one deliberately generic escape hatch, and let the consumer override the primitive itself. And when something works, prove it worked by watching it happen, not by trusting that a green build means what you hoped it meant — the bug that actually mattered on this project never showed up as a build failure. It showed up as a string that shouldn't have been there, in a bundle nobody would have thought to check.

Subscribe to get new posts

Subscribe to But does it scale
Peakub is a home for creators, publishers, and teams to publish, grow, and monetize their work — all in one place. We're building a fast, privacy‑respecting platform that helps you focus on your craft while Peakub handles the plumbing — from content tooling to delivery and analytics.
By
Avatar

But does it scale

System design at scale for real life platforms. We study constraints, solutions and tools that make innovation possible for millions.
Subscribe

Comments

Send