Software engineering · · 5 min read · By the Cirlume studio
Covers React Native & Expo, React, TypeScript
One codebase, three platforms: iOS, Android and web with Expo
Lessons on sharing code, drawing with Skia and handling phone, tablet and desktop layouts from one Expo codebase.

Building the same product three times, once each for iOS, Android and the web, triples the work and guarantees the versions drift apart. A shared codebase promises to fix that, but only if you are clear about what should be shared and where each platform needs its own answer.
SimRhythm, our ECG and defibrillator training simulator, runs on iPhone and iPad, Android and the web from one Expo, React Native and TypeScript codebase. Traces are drawn with React Native Skia. This article covers what we share, where the platforms differ and how releases fit together.
Why one codebase
The case for a single codebase is strongest when the product logic is the hard part and the interface is custom. In SimRhythm, the monitor display, the rhythm selection, the vital sign controls and the scenario presets behave the same everywhere. Writing them once means an educator gets the same simulator whichever device is in front of them, and a fix lands on every platform at once.
It is a weaker case when the product leans on platform-specific features, such as deep system integrations, or when each platform’s users expect very different interfaces. A single codebase can still work there, but the shared portion shrinks and the cost of the abstraction grows.
What to share
Aim to share everything that does not depend on the platform, and keep it in plain TypeScript where possible.
- State and logic. The current rhythm, vital signs, timer and control state live in shared modules with no platform imports. They are the easiest code to test.
- Data. Rhythm definitions, scenario presets and their starting values are shared data, not code duplicated per platform.
- Components. Buttons, panels, the control dock and the monitor layout are React Native components that render on all three targets.
- Navigation. Expo Router gives one file-based route structure for native and web, including URLs on the web.

Where platforms differ
Some differences are worth accepting rather than hiding.
Input
Touch on a phone, touch with larger targets on a tablet, and mouse and keyboard on the web. Hover states, keyboard shortcuts and focus outlines matter on the web and are irrelevant on a phone. Design each control so it works with the weakest input, then add the extras where they are available.
Audio and timing
Sounds such as alarms and charge tones behave differently across platforms, especially in browsers, which often block audio until the user interacts with the page. Plan for that in the first-run flow, not as a late fix.
Platform files
When a component genuinely needs different code, use platform-specific files (Component.ios.tsx, Component.web.tsx) and keep them small. If more than a handful of components need this, it is worth asking whether those parts should be shared at all.
Drawing with React Native Skia
A training monitor needs traces that move smoothly and stay in step. Standard layout components are not designed for that. React Native Skia gives a drawing surface that runs on iOS, Android and the web, so the same drawing code serves every platform.
In SimRhythm, the ECG combines supplied rhythm strips with generated traces, and capnography is generated. A shared sweep clock keeps the two traces aligned, so they move across the monitor at the same position.
The following is a simplified example of the idea, not our production code:
// One clock drives every trace, so sweeps stay aligned.
type Trace = { samples: number[]; sampleRate: number };
export function sweepPosition(elapsedMs: number, sweepMs: number) {
return (elapsedMs % sweepMs) / sweepMs; // 0..1 across the screen
}
export function visibleSamples(trace: Trace, elapsedMs: number, sweepMs: number) {
const perSweep = Math.round((sweepMs / 1000) * trace.sampleRate);
const end = Math.floor((elapsedMs / 1000) * trace.sampleRate);
const start = Math.max(0, end - perSweep);
return trace.samples.slice(start, end);
}
A few practical points from this kind of work:
- Keep drawing state out of React state. Re-rendering components on every frame is expensive. Let the drawing layer read shared values and redraw itself.
- Test on the slowest target early. A mid-range Android phone and a browser on an older laptop will show performance problems a new iPad hides.
- Size by the screen, not by pixels. Compute trace scale from the available width and height so the monitor reads the same on a phone and a desktop.
Layouts from phone to tablet to desktop
The same product on a phone, an iPad and a wide browser window is three different layouts, not one layout stretched.
- Decide what must stay visible. For a training monitor, the traces and key observations should never be pushed off screen by controls. Everything else adapts around them.
- Use breakpoints by available space. Base layout decisions on the window size, not the device type. An iPad in split view can be narrower than a phone in landscape.
- Move, don’t shrink. On a phone, the control dock might sit below the monitor. On a tablet or desktop, it can sit beside it. Shrinking every control to fit the smallest screen makes the larger screens worse.
- Check orientation changes. Rotating a tablet mid-session should not reset the simulator or lose the current scenario.
Release process
Three platforms means three release routes: the App Store, Google Play and a web deployment. Keeping them in step takes some structure.
- One version number. Tag a release once and build all three targets from that tag, so a bug report can be traced to the exact code.
- Automate builds. Native builds should run in CI, not on one person’s laptop, with signing credentials held in the build service rather than shared by hand.
- Separate native and JavaScript changes. Changes that only touch JavaScript can, with care, be delivered as over-the-air updates. Changes to native modules need a new store build. Know which kind each release is.
- Allow for review time. App store review adds time the web does not have. Plan releases so the web is not waiting on a store, or users on different platforms will see different versions.
The trade-offs
A shared codebase is not free. You depend on React Native, Expo and Skia keeping pace with each platform, and upgrades need to be planned rather than left to pile up. Some platform behaviour will need workarounds. The web build in particular is not the same as a web app built for the browser from the start: it suits app-like products more than content-heavy sites.
For a custom, interactive product like a training simulator, those costs are usually smaller than maintaining three separate apps.
When to bring in help
If you are planning a product for more than one platform, or maintaining separate apps that have drifted apart, it is worth deciding early what should be shared and how releases will work. We can help review the options, prototype the hardest screen on each platform and set up a build and release process your team can run.