Kotlin Multiplatform (KMP) vs. React Native: Shared Business Logic vs. Shared UI Components
Architectural Foundations: KMP for Logic, React Native for UI
When evaluating Kotlin Multiplatform (KMP) against React Native for cross-platform mobile development, a critical distinction emerges: their primary strengths lie in different domains. KMP excels at sharing *business logic* across platforms (iOS, Android, and even server-side), while React Native’s core value proposition is sharing *UI components* and application structure. This fundamental difference dictates the architectural patterns and trade-offs involved in each approach.
A common and highly effective strategy is to leverage KMP for the core, platform-agnostic business logic and data management, and then integrate this KMP module into native iOS (Swift/Objective-C) and Android (Kotlin/Java) applications, or even into a React Native application where the KMP module acts as a native bridge for complex computations or data operations.
Kotlin Multiplatform: Sharing Business Logic
KMP allows you to write shared code in Kotlin that can be compiled into native binaries for different platforms. This means your business logic, data models, network calls, and even complex algorithms can be written once and executed natively on both iOS and Android, offering near-native performance and seamless integration.
Consider a typical data repository pattern. This logic can be entirely defined in a shared KMP module.
Shared KMP Module Example (Kotlin)
Let’s define a simple `UserRepository` that fetches user data. This code would reside in your shared KMP source set.
// shared/src/commonMain/kotlin/com/example/kmpdemo/data/UserRepository.kt
package com.example.kmpdemo.data
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class User(val id: Int, val name: String, val email: String)
interface UserRepository {
suspend fun getUser(userId: Int): User?
}
class ApiUserRepository(private val httpClient: HttpClient) : UserRepository {
private val baseUrl = "https://api.example.com/users"
override suspend fun getUser(userId: Int): User? {
return try {
httpClient.get("$baseUrl/$userId").body()
} catch (e: Exception) {
// Handle exceptions appropriately (logging, error reporting)
println("Error fetching user: ${e.message}")
null
}
}
}
// Factory function to create the HTTP client, allowing platform-specific configurations
// This would be defined in expect/actual declarations
expect fun createHttpClient(): HttpClient
// Common module setup
fun commonKoinModule() = org.koin.dsl.module {
single { ApiUserRepository(get()) }
single { createHttpClient() } // Injecting the platform-specific client
}
The `expect`/`actual` mechanism in Kotlin Multiplatform is crucial for handling platform-specific implementations, such as the `HttpClient` creation. This allows you to use Ktor’s HTTP client, which has excellent multiplatform support.
Platform-Specific Implementations (Android & iOS)
For Android, the `actual` implementation might use OkHttp:
// shared/src/androidMain/kotlin/com/example/kmpdemo/data/HttpClientFactory.android.kt
package com.example.kmpdemo.data
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import org.koin.dsl.module
actual fun createHttpClient(): HttpClient {
return HttpClient(OkHttp) {
engine {
config {
// OkHttp specific configurations
}
}
install(ContentNegotiation) {
json(Json {
prettyPrint = true
ignoreUnknownKeys = true
})
}
// Other Ktor configurations
}
}
// Android specific Koin module to include common module
fun androidKoinModule() = module {
includes(commonKoinModule())
// Android specific dependencies
}
For iOS, the `actual` implementation would use Darwin (Cocoa):
// shared/src/iosMain/kotlin/com/example/kmpdemo/data/HttpClientFactory.ios.kt
package com.example.kmpdemo.data
import io.ktor.client.*
import io.ktor.client.engine.darwin.*
import org.koin.dsl.module
actual fun createHttpClient(): HttpClient {
return HttpClient(Darwin) {
engine {
configureRequest {
// Darwin specific configurations
}
}
install(ContentNegotiation) {
json(Json {
prettyPrint = true
ignoreUnknownKeys = true
})
}
// Other Ktor configurations
}
}
// iOS specific Koin module to include common module
fun iosKoinModule() = module {
includes(commonKoinModule())
// iOS specific dependencies
}
This shared logic can then be consumed by native Android and iOS applications, or even exposed to React Native via a native module.
React Native: Sharing UI Components and Application Structure
React Native’s strength lies in its declarative UI paradigm using React. You write your UI components in JavaScript/TypeScript, and React Native renders them as native UI elements. This allows for significant code sharing for the user interface and application flow.
However, integrating complex, performance-sensitive business logic directly into React Native can lead to performance bottlenecks, especially if that logic involves heavy computation or frequent native interactions. This is where KMP can complement React Native.
Integrating KMP with React Native
The most robust way to integrate a KMP module into a React Native application is by creating a native module for each platform (iOS and Android) that bridges to your KMP shared code. This allows React Native JavaScript code to call into your performant, shared Kotlin logic.
Android: React Native Native Module for KMP
First, ensure your KMP module is correctly set up as a Gradle dependency within your Android project. Then, create a standard React Native Java module.
// android/app/src/main/java/com/your_app_name/KmpBridgeModule.java
package com.your_app_name;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.example.kmpdemo.data.ApiUserRepository; // Import your KMP class
import com.example.kmpdemo.data.User;
import com.example.kmpdemo.data.UserRepository;
import com.example.kmpdemo.data.androidKoinModule; // Assuming Koin setup
import org.koin.core.context.KoinContextHandler;
import org.koin.core.context.startKoin;
import org.koin.core.logger.Level;
public class KmpBridgeModule extends ReactContextBaseJavaModule {
private static ReactApplicationContext reactContext;
private UserRepository userRepository; // Your KMP dependency
KmpBridgeModule(ReactApplicationContext context) {
super(context);
reactContext = context;
// Initialize Koin for the KMP module
if (KoinContextHandler.getOrNull() == null) {
startKoin(new org.koin.android.compat.KoinAndroidContext(context.getApplicationContext()), androidKoinModule());
}
// Get the UserRepository instance from Koin
userRepository = KoinContextHandler.get().get(UserRepository.class);
}
@Override
public String getName() {
return "KmpBridge";
}
@ReactMethod
public void getUser(int userId, Promise promise) {
// Use Kotlin Coroutines with React Native Promises
// This requires a way to bridge suspend functions to callbacks
// A common approach is to use a helper or a library like 'react-native-kotlin-coroutines'
// For simplicity, we'll simulate a non-blocking call here.
// In a real app, you'd use a proper coroutine dispatcher.
new Thread(() -> {
try {
// Execute the suspend function from KMP
User user = userRepository.getUser(userId); // This is a suspend function in Kotlin
if (user != null) {
WritableMap map = new WritableNativeMap();
map.putInt("id", user.getId());
map.putString("name", user.getName());
map.putString("email", user.getEmail());
promise.resolve(map);
} else {
promise.reject("USER_NOT_FOUND", "User with ID " + userId + " not found.");
}
} catch (Exception e) {
promise.reject("ERROR", "Failed to fetch user: " + e.getMessage());
}
}).start();
}
}
// android/app/src/main/java/com/your_app_name/KmpBridgePackage.java
package com.your_app_name;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class KmpBridgePackage implements ReactPackage {
@Override
public List createNativeModules(ReactApplicationContext reactContext) {
List modules = new ArrayList<>();
modules.add(new KmpBridgeModule(reactContext));
return modules;
}
@Override
public List createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
}
// android/app/src/main/java/com/your_app_name/MainApplication.java (add to getPackages method) // ... other imports import com.your_app_name.KmpBridgePackage; // Import your package // ... inside MainApplication class @Override protected ListgetPackages() { @SuppressWarnings("UnnecessaryLocalVariable") List packages = new PackageList(this).getPackages(); // Packages that cannot be autolinked yet can be added manually here, for example: packages.add(new KmpBridgePackage()); // Add your KMP bridge package return packages; } // ...
Note on Coroutines: Bridging Kotlin Coroutines (used by Ktor and KMP) to React Native’s asynchronous model requires careful handling. Libraries like react-native-kotlin-coroutines can simplify this by providing mechanisms to convert suspend functions into Promises.
iOS: React Native Native Module for KMP
For iOS, you’ll need to expose your KMP module via Objective-C++ and then bridge that to React Native.
// shared/src/iosMain/kotlin/com/example/kmpdemo/data/KmpBridge.kt
package com.example.kmpdemo.data
import io.ktor.client.*
import kotlinx.coroutines.*
import org.koin.core.context.startKoin
import org.koin.dsl.module
// Expose Koin initialization and UserRepository to Objective-C
@OptIn(ExperimentalCoroutinesApi::class)
@CName("initializeKmpBridge")
fun initializeKmpBridge() {
if (KoinContextHandler.getOrNull() == null) {
startKoin {
printLogger(level = Level.DEBUG)
includes(iosKoinModule()) // Use the iOS specific Koin module
}
}
}
@CName("getUserFromKmp")
suspend fun getUserFromKmp(userId: Int): User? {
val userRepository: UserRepository = KoinContextHandler.get().get()
return userRepository.getUser(userId)
}
// Helper to run suspend functions from Objective-C
@CName("runBlockingKmp")
fun runBlockingKmp(block: suspend () -> Unit) {
// Use MainScope for iOS, or a custom scope if needed
// Ensure proper lifecycle management to avoid memory leaks
// For simplicity, using GlobalScope here, but a scoped approach is better in production
GlobalScope.launch {
block()
}
}
// ios/KmpBridge.h
#import <Foundation/Foundation.h>
// Forward declarations
@class RCTPromiseResolveBlock;
@class RCTPromiseRejectBlock;
NS_ASSUME_NONNULL_BEGIN
// Declare functions exposed from Kotlin
void initializeKmpBridge(void);
void runBlockingKmp(void (^block)(void)); // Block to run suspend functions
// Declare a class to bridge to React Native
@interface KmpBridge : NSObject
// Method to get user, will be called from React Native
- (void)getUser:(NSInteger)userId
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject;
@end
NS_ASSUME_NONNULL_END
// ios/KmpBridge.m
#import "KmpBridge.h"
#import <React/RCTLog.h>
#import <React/RCTConvert.h>
// Import the Kotlin generated header
// This header is generated by the Kotlin compiler for multiplatform projects
// Ensure your build settings correctly link the KMP framework
#import "YourAppName-Swift.h" // Replace YourAppName with your actual app name
@implementation KmpBridge
// Export the module to React Native
RCT_EXPORT_MODULE();
// Initialize KMP when the module is loaded
- (instancetype)init {
if (self = [super init]) {
initializeKmpBridge(); // Call the Kotlin function to initialize Koin
}
return self;
}
// Export the getUser method to JavaScript
RCT_EXPORT_METHOD(getUser:(NSInteger)userId
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
// Use runBlockingKmp to bridge the suspend function
runBlockingKmp(^{
// Call the suspend function from KMP
// Note: This requires the Kotlin code to be compiled into a framework
// and properly linked. The actual function call might look different
// depending on how Kotlin exposes it.
// Assuming getUserFromKmp is accessible and returns a Kotlin object
// that can be converted to a dictionary.
// You might need a Kotlin-to-Objective-C data conversion layer.
// Placeholder for actual KMP call and data conversion
// In a real scenario, you'd convert Kotlin's User object to NSDictionary
// For demonstration, let's simulate a successful response.
dispatch_async(dispatch_get_main_queue(), ^{
// Simulate fetching user data
NSDictionary *userData = @{
@"id": @(userId),
@"name": @"John Doe",
@"email": @"[email protected]"
};
resolve(userData);
// If an error occurs in KMP, call reject:
// reject(@"USER_NOT_FOUND", @"User not found", nil);
});
});
}
@end
Bridging Considerations: The `runBlockingKmp` helper is a simplified example. In production, managing the coroutine scope and ensuring proper thread handling is critical to avoid crashes or memory leaks. You’ll also need to handle the conversion of Kotlin data types (like `User`) to Objective-C/Swift types (like `NSDictionary` or custom Swift objects) that React Native can serialize.
React Native JavaScript Usage
Once the native modules are set up, you can call your KMP logic from your React Native JavaScript code.
// src/components/UserProfile.js
import React, { useState, useEffect } from 'react';
import { View, Text, Button, NativeModules } from 'react-native';
const { KmpBridge } = NativeModules; // Access the native module
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const userData = await KmpBridge.getUser(userId);
setUser(userData);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUser();
}, [userId]);
return (
<View>
<Text>User Profile for ID: {userId}</Text>
{loading && <Text>Loading...</Text>}
{error && <Text style={{ color: 'red' }}>Error: {error}</Text>}
{user && (
<View>
<Text>Name: {user.name}</Text>
<Text>Email: {user.email}</Text>
</View>
)}
<Button title="Refresh" onPress={fetchUser} />
</View>
);
};
export default UserProfile;
When to Choose Which Approach
Choose KMP for:
- Sharing complex business logic, data validation, algorithms, and networking code.
- Achieving near-native performance for computationally intensive tasks.
- Maintaining a single source of truth for core application logic across platforms.
- When you need to share logic with native iOS and Android apps that are not built with React Native.
Choose React Native for:
- Sharing UI components, navigation, and application structure.
- Rapid UI development and prototyping.
- Teams with strong JavaScript/React expertise.
- When the majority of the application’s complexity lies in the UI and user experience.
Hybrid Approach (KMP + React Native):
- This is often the most powerful combination. Use KMP for your core business logic, data layer, and any performance-critical operations.
- Use React Native for the UI layer, rendering components, and orchestrating user interactions.
- Integrate the KMP module into React Native via native modules, as demonstrated above. This allows you to benefit from KMP’s shared logic and React Native’s UI development speed.
This hybrid strategy offers the best of both worlds: robust, performant, and maintainable business logic shared across platforms, coupled with efficient UI development and a rich ecosystem provided by React Native.