KMP Shares Logic, React Native Shares UI — and That Changes Everything
Can you believe it? Even though both are cross-platform, KMP and RN are this different!
Author: Huang Linqing Tags: Android, Frontend
I don't know how you choose when doing cross-platform hybrid development, i.e., native Android + native iOS + a cross-platform lib. Kotlin Multiplatform and React Native can both reduce duplicate code, but they place the shared layer in completely different positions.
The former usually retains native UIs on both ends, putting domain logic, data access, and the network layer into commonMain; the latter hands most of the interface and interaction to JavaScript/TypeScript, then accesses device capabilities through native modules. Choosing KMP often means compressing duplicate business code while preserving platform boundaries.
Where to Share
In a KMP project, Android and iOS can continue to maintain their own page entries, navigation, system components, and platform interactions. The shared module doesn't need to know whether the current page is Compose, XML, or SwiftUI; it only provides UseCase, Repository, serialization models, and state.
This is close to the layering of many existing mobile projects. When Android already has data, domain, and feature modules, the focus of migration is usually moving the pure Kotlin parts to commonMain, rather than rewriting the entire UI.
Below is a common login state usage. SessionRepository is in the shared module, and both Android's ViewModel and iOS's ObservableObject can call it:
// shared/src/commonMain/kotlin/session/SessionRepository.kt
class SessionRepository(
private val api: AccountApi,
private val tokenStore: TokenStore,
) {
suspend fun signIn(email: String, password: String): Session {
val session = api.signIn(SignInRequest(email, password))
tokenStore.save(session.accessToken)
return session
}
}
The interfaces for AccountApi and TokenStore are also placed in commonMain. Android can use implementations with Ktor + SharedPreferences or DataStore, and iOS can use implementations with the Ktor Darwin engine and Keychain. Business callers all get the same Session, without needing to maintain two sets of interface fields and error mappings.
When an Android project is already writing business logic in Kotlin, this migration's changes are relatively concentrated: move code without platform dependencies to the shared module, leave interfaces for capabilities like storage, push tokens, and file paths, and then provide implementations in androidMain and iosMain.
// shared/build.gradle.kts
kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation(libs.ktor.client.core)
implementation(libs.kotlinx.serialization.json)
}
}
}
Here, Android's Context, ViewModel, or Compose APIs are not placed in the shared layer. They still belong to Android; similarly, SwiftUI and Keychain still belong to iOS. The shared module only depends on cross-platform libraries and its own defined abstractions, making the compilation boundary clearer.
UI Remains on the Platform Side
React Native's advantage is that the interface can also be shared. Pages are centered on JS/TS components, with a main set of rendering and interaction code for both Android and iOS. When there are many forms, lists, and business pages with visually consistent designs on both ends, this can significantly reduce duplicate work.
But for native teams, UI unification also changes the original division of responsibilities. Android's Compose components and iOS's SwiftUI components are no longer the default entry points; when encountering complex animations, system pages, Widgets, notification extensions, or platform SDKs, you need to call native modules from the JS side and then maintain the bridge layer and implementations on both ends.
KMP's trade-off is exactly the opposite. It accepts the cost of two UIs and limits the sharing scope to the logic and data layers. When design drafts have obvious differences between the two ends, the product relies on system interactions, or Android/iOS already have mature code, native UI can avoid an extra layer of adaptation.
This doesn't mean KMP has no UI solution. Compose Multiplatform can already cover some multi-platform interfaces, but whether to share the UI is a separate decision. When a project first shares domain and data, it doesn't need to change all pages at the same time.
Code Ownership
Architectural choices also affect how a team handles requirements. React Native usually requires a team capable of maintaining JS/TS pages and native modules; platform-side requirements often pass through the shared layer before landing in Android/iOS implementations.
KMP is more suitable for allowing platform engineers to continue owning their own UI and system integrations. The shared module is maintained by developers familiar with Kotlin, and iOS engineers can still handle interfaces in the Swift/SwiftUI way. Interface changes are concentrated in shared models and API contracts, making it easier to see during review whether a change affects Android, iOS, or both ends.
A practical directory can be kept very simple:
shared/
src/commonMain/ → model, network, repository, use case
src/androidMain/ → Android storage, network engine implementation
src/iosMain/ → Keychain, Darwin network engine implementation
androidApp/ → Compose / ViewModel / Android system capabilities
iosApp/ → SwiftUI / iOS system capabilities
Shared code should not have reverse dependencies on the page layer. For example, if a Toast needs to be shown after an order is submitted, commonMain returns a Result or domain error; the Android ViewModel and iOS page each decide on the prompt text and display method. This way, platform UI APIs are not introduced into the core module just to share a prompt box.
Migration Cost
The barrier for KMP isn't moving Kotlin files to commonMain, but whether the boundaries are clean. Code that directly reads Build.VERSION, passes Context, or depends on AndroidX lifecycle components cannot be reused as-is. Move these to the Android entry point or interface implementation first, and compilation errors will tell you which dependencies haven't been separated yet.
iOS also incurs engineering costs. Kotlin/Native artifacts need to be integrated into Xcode, coroutines and Flow exposure must be easy for Swift callers to consume, and network errors and thread switching need to be verified on real devices. Sharing business logic does not mean the iOS project requires no maintenance.
React Native also has its own costs: upgrading React Native versions, handling native dependency compatibility, and debugging issues between JS and native. Neither solution is a "write once, never worry about the platform again" tool; the difference is only in which layer the complexity is concentrated.
For a product that already has Android/iOS clients with many UI differences, sharing pure business code like accounts, products, orders, search, and caching is usually more stable than rewriting all pages at once. If the team's goal is a highly unified cross-platform UI and it has the long-term capability to maintain JS/TS and native modules, the benefits of React Native will be more direct.
Finally
What cross-platform solution are you currently using? KMP? RN? Or Flutter?