Est.
React NativeLong read

Code Sharing Strategy for Teams Maintaining React Native and Native iOS Codebases

Separate shared logic from native code to avoid duplicating fixes across platforms.

Staff Writer · · 10 min read
Cover illustration for “Code Sharing Strategy for Teams Maintaining React Native and Native iOS Codebases”
React Native · October 7, 2026 · 10 min read · 2,359 words

If business logic lives in both a TypeScript layer and a Swift layer without a deliberate boundary between them, a team running React Native alongside a native iOS codebase pays a duplication tax. Every bug fix has to be made twice, and the two fixes rarely match: a validation rule patched in the shared layer drifts from the version patched in Swift, and six months later nobody can say which one is correct. The tax compounds as the product ages. What starts as a minor duplication of effort on a two-feature app becomes a release bottleneck once the feature surface doubles or triples, because every change now has to be reasoned about in two languages, tested in two environments, and reviewed by two sets of engineers who may not talk to each other daily.

The common failure pattern looks reasonable from the inside. A team adopts React Native for one feature, leaves its existing native surfaces untouched, and carries over assumptions from a web codebase into packages that are meant to be shared across mobile. None of those three decisions is wrong in isolation. Together, they mean nearly every change crosses an ownership boundary that nobody defined on purpose, and delivery slows because a single feature request now needs sign-off, context, and testing from teams who were never meant to be coupled this tightly. The question a team faces here is where the line between shared code and native code sits, who owns each side of that line, and what mechanism keeps the line from eroding as the roadmap gets more demanding. Everything that follows, from the package structure to the CI pipeline, is a consequence of answering that question early and deliberately, or not answering it and absorbing the cost later.

What the boundary separates: shared logic vs. native capability

The boundary that survives contact with a real product roadmap separates code that is genuinely platform-agnostic from code that depends on Apple's platform in ways no abstraction layer can replicate without losing something. That is a narrower definition than "JavaScript versus Swift," and teams that draw the line by language rather than by dependency tend to redraw it painfully a year later.

The shared layer holds domain logic: data types, validation rules, API clients, and state management, any code whose correctness has nothing to do with which operating system happens to be running it. In a monorepo, that logic typically lives in packages such as packages/core and packages/state, and the discipline that keeps those packages honest is refusing to let browser-only or mobile-only APIs leak into them unless they're deliberately wrapped behind an interface built for that purpose. Tools like Yarn Workspaces, Nx, or Turborepo exist specifically to enforce that discipline: they let a team declare dependency rules once and then catch violations automatically rather than relying on every engineer remembering the rule.

The native layer is everything on the other side: code that touches Apple's hardware, its rendering pipeline, or platform APIs that React Native can only reach through a bridge, and sometimes can barely reach even then. The specific capabilities that belong there get their own treatment further on. What matters at the definitional level is a pattern most teams get wrong in the same direction: they assume that because business logic shares well, the UI built on top of it will share just as well. Business logic can be shared heavily. UI can only be shared partially, because the design conventions and gesture models of iOS and Android diverge in ways users notice immediately, even when they can't articulate why a screen feels slightly wrong.

Which capabilities belong in the shared React Native layer

The technical case for sharing as much business logic as possible got considerably stronger with the New Architecture. JSI lets JavaScript and native code call each other directly and synchronously, removing the JSON serialization step that used to sit in the middle of every bridge call. Fabric moves layout calculations into a shared C++ layer, so they no longer have to sit in platform-specific rendering code. TurboModules load native modules on demand instead of loading everything at startup, which cuts the cost of keeping a large module surface around.

Given that foundation, the shared layer should hold all domain and business logic without exception: validation rules, data transformation, API integration, and state management, none of which has any platform-specific reason to be duplicated. It should also hold standard UI flows where the two platforms don't meaningfully diverge: forms, lists, navigation shells, and ordinary content screens. These are the cases where sharing saves real engineering time without asking users to tolerate an interface that feels foreign to their device.

Two companies show the ceiling here; they are not a norm every team should chase. Shopify finished moving its flagship app entirely onto React Native by 2025, reaching roughly 86% shared code between iOS and Android, and kept shipping weekly releases the entire time. Instagram shares a high proportion of code across platforms depending on the feature, and it does so through a hybrid approach: React Native handles the features where development speed matters most, native code handles the surfaces where platform feel is what users actually judge the app on. Neither case argues that everything should be shared. Both show what's achievable once a team has drawn the boundary correctly and built the discipline to keep logic out of the native layer where it doesn't belong.

Capabilities that must live in native Swift and cannot be delegated to the shared layer

Some iOS capabilities lose their value the moment they're routed through a cross-platform abstraction. The abstraction adds latency, gives up platform integration the user would otherwise get for free, or demands so much custom bridging work that whatever time the shared layer saved gets spent rebuilding it anyway. It reflects what these capabilities are for.

Performance-critical rendering belongs in native Swift: video decoding, complex animation work, and gesture systems where frame-level precision is the entire point of the feature. Deep hardware integration belongs there too, covering camera pipelines, ARKit, real-time audio, Bluetooth peripherals, and any API Apple ships that requires direct access to the operating system. SwiftUI-native surfaces where platform convention carries real weight also belong on the native side: widgets, Live Activities, App Intents, and the system extension points Apple builds so they feel like iOS itself rather than a component dropped in from somewhere else.

On-device AI inference is the clearest example of this native territory expanding. Apple's Core AI framework, announced at WWDC 2026, runs large language models locally inside native Swift, and Core ML handles custom model deployment, both of them Swift-native with no real production equivalent on the React Native side. The common objection says React Native ExecuTorch already supports on-device inference across platforms, but that holds up only partially. ExecuTorch does cover models like Qwen 3, Llama 3.2, and Whisper, and it does use Core ML as its hardware backend on iOS. It lacks Core AI's Swift-native sessions, its guided generation, and its App Intents integration, and that combination is what makes on-device AI feel like a feature Apple built into the platform. For teams aiming at the deepest level of platform integration, that gap is exactly where the native layer earns its keep, and it's a gap Apple's own investment is actively widening.

Drawing the line in a real codebase: the monorepo package split in practice

A boundary that lives only in a team's shared understanding will not survive a deadline. It has to show up as a rule the build system enforces, in the directory structure itself.

A workable split starts with packages/core, holding domain types, validation logic, and API clients, with no platform imports permitted anywhere in it. Next to it sits packages/state, holding shared stores and derived state, held to the same no-platform-imports rule. The React Native UI layer lives in its own package, commonly named something like packages/ui-native, where components get platform-adapted as needed without pulling business logic in from the outside. On the native side, Swift code lives in its own directory or Swift Package; it exposes a clean interface to the React Native layer through a bridge, and business logic stays entirely out of that bridge.

The naming convention does not make this structure durable; the enforcement does. Yarn Workspaces, Nx, or Turborepo can all make these dependency boundaries explicit, so that an attempt to import a mobile-only API into packages/core fails the build instead of waiting to be caught in a code review that might miss it. That distinction matters more as a team grows past the size where everyone remembers every rule. The most common way this boundary erodes is business logic quietly migrating into UI components under feature-delivery pressure, one small shortcut at a time, until the UI layer is carrying rules it was never supposed to own. A second failure mode occurs on the native side: every new native capability that lacks a clean interface adds its own coordination overhead, and left unchecked, the bridge turns from a deliberate, designed surface into an accumulation of modules nobody fully planned.

SwiftUI architecture for the native layer: keeping it clean enough to bridge

The boundary can be placed correctly in the codebase and still cause problems if the native layer behind it is a mess. A Swift layer with business logic scattered across its views is just as hard to maintain as the original duplication across two layers, because the logic is still spread out even though it now lives in a single place.

For native layers carrying real complexity, meaning on-device AI sessions, multi-agent coordination, or real-time hardware streams, The Composable Architecture gives the layer unidirectional data flow, a single source of truth, and dependency injection built in from the start. That combination makes the layer easier to test on its own and considerably safer to expose across a bridge, because a bridge consumer only has to reason about one predictable state shape rather than tracking mutations scattered across a view hierarchy.

Persistence needs the same discipline. If you structure SwiftData around a single ModelContainer at the app level, with repositories that speak in domain terms and handle ModelContext internally, business logic and ViewModel state kept in plain Swift types, and all mutations routed through ViewModels to those repositories rather than touching persistence directly from a view, it gives the native layer a proper persistence story.

Organizing the codebase by feature reinforces the same goal. Each feature keeps its own Views, ViewModels, and Models self-contained, with shared UI components and theming held in a separate layer, so it's easy to look at the project and know immediately which features are native-only and which ones expose an interface across the bridge. Concurrency discipline closes the loop: Sendable annotations, avoiding direct access to @MainActor properties across the bridge boundary, and correct capture lists for values crossing into async contexts all prevent data races that are painful to trace once they surface as a mysterious bug on the JavaScript side, far from where they actually originated in Swift.

Ownership models that match the architecture: who owns what across the boundary

A package boundary drawn correctly in code creates a coordination point between teams, and if ownership isn't clear on both sides of it, that coordination point becomes the slowest part of the entire delivery process. The fix is a deliberate ownership model designed to match the architecture, rather than one that emerges on its own as the team grows.

The model that tends to hold: a platform team owns the infrastructure everyone depends on, meaning developer tooling, observability, CI/CD, and the bridge layer itself, while product squads own specific domains end-to-end inside the boundaries that platform team maintains, including the judgment call of which side of the line their own feature belongs on. A workable squad for this looks like roughly ten people: engineers, a product manager, a designer, and a tech lead, organized around one business domain, with the tech lead directly accountable for keeping that domain's code on the correct side of the boundary.

Delivery slows down when every decision about where a capability belongs has to be escalated to a central architecture group for approval. The fix is to make the boundary a documented decision rule a tech lead can apply directly, not a case-by-case negotiation that adds a meeting to every feature. The bridge layer deserves the same clarity of ownership as any domain package: it needs one accountable owner rather than being treated as something everyone is responsible for, because in practice, shared responsibility for a bridge means nobody notices the ad hoc modules accumulating in it until the accumulation has already become a real maintenance problem.

CI/CD and tooling that enforces the boundary as the team scales

A boundary written down in an architecture document starts to erode the first time a deadline gets tight, because documents don't block a merge. The teams whose architecture holds up over years treat the boundary as something the build system enforces, not something a reviewer is expected to catch by memory.

Dependency linting inside the monorepo, whether through Nx module boundaries, Turborepo pipeline constraints, or custom ESLint rules, turns an attempted import of a platform-specific API into packages/core into a CI failure rather than a comment on a pull request that might get overruled under pressure. Running separate CI pipelines for the shared layer and the native layer stops a change on one side from silently breaking the other, and the bridge between them functions as the integration test that catches what neither pipeline could catch alone.

Release cadence is where the payoff of all this is visible. Discord cut its median startup time in half, and one targeted React Native 0.79 optimization cut time-to-interactive by 400ms on mid-range Android devices. That kind of precise, isolated improvement is only possible when the boundary between shared and native code is clean enough that a team can change one layer with confidence about what it will and won't affect elsewhere. The tooling is the mechanism that keeps the architectural decision in force once the team making it has grown too large to enforce it by memory alone.

Filed underReact Native

More in React Native