
If you’ve been writing Android apps for a while, Kotlin Flows probably feel like second nature. You know StateFlow for UI state, SharedFlow for one-off events, and you’ve long said goodbye to LiveData. But the moment you bring KMP into the picture, the bridge between Kotlin and Swift introduces a new set of challenges.
In this article, we’ll go from a quick recap of StateFlow vs SharedFlow, through the KMP bridge problem, all the way to a working shared ViewModel consumed natively on both Android (Jetpack Compose) and iOS (SwiftUI). We’ll also cover the real gotchas that don’t usually make it into the docs.
The Quick Lap: StateFlow vs SharedFlow
Before we hit the KMP complexity, let’s align on the fundamentals.
StateFlow
StateFlow is a hot, stateful flow that always holds a current value. It behaves like an observable variable — any collector immediately receives the latest value upon subscription.
val lapCount: StateFlow<Int> = MutableStateFlow(0)
When to use it: UI state. Anything the screen needs to display and should survive re-subscriptions (e.g., loading state, data models, counters).
Key traits:
- Always has a value (requires an initial value)
- Emits only distinct values (no duplicate emissions)
- New collectors get the current value immediately
SharedFlow
SharedFlow is a hot, event-based flow with no persistent state. It’s designed for broadcasting events to multiple collectors — but only those actively listening at the time of emission will receive it.
val pitStopAlert: SharedFlow<String> = MutableSharedFlow()
When to use it: One-time events. Navigation, error toasts, dialogs — anything that should fire once and not replay on re-subscription.
Key traits:
- No initial value (unless you configure
replay) - Does not deduplicate values
- Collectors only receive emissions that happen after they start collecting
The One-Sentence Rule
Use
StateFlowwhen your UI needs to know something. UseSharedFlowwhen your UI needs to react to something.
The KMP Problem: Flows Don’t Cross the Bridge Natively
Here’s where it gets interesting. When you compile a KMP module for iOS, Kotlin/Native comes into play — and Kotlin coroutines and Flows are not natively consumable in Swift.
Swift doesn’t understand StateFlow<T> or SharedFlow<T>. You can’t just call .collect {} from Swift. If you try to expose a raw StateFlow from your shared module, you’ll end up with something Swift sees as a generic Any type — basically useless.
There are a few approaches to bridge this gap:
- KMP-NativeCoroutines — a library that generates Swift-friendly wrappers automatically
- SKIE (by Touchlab) — another powerful option for Swift/Kotlin interop
- Manual wrapping — writing your own
iosMainwrapper usingDisposableHandle
For this article, we’ll go with the manual wrapping approach. It’s more verbose, but it gives you full visibility into what’s happening — which is exactly what you want when you’re learning the mechanics.
The Architecture: Shared ViewModel
Here’s the structure of our shared module:
shared/
├── commonMain/
│ └── PitStopViewModel.kt ← works for both platforms
└── iosMain/
└── PitStopViewModelWrapper.kt ← Swift-friendly bridge
commonMain: The Shared ViewModel
Good news: androidx.lifecycle.ViewModel is no longer Android-only. Since lifecycle-viewmodel became a KMP artifact, you can extend it directly in commonMain and get viewModelScope for free on both platforms — no manual scope management needed.
Make sure you have the dependency in your commonMain:
// build.gradle.kts (shared module)
commonMain.dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel:2.8.0")
}
Now the ViewModel is clean and platform-agnostic:
// commonMain/PitStopViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class PitStopViewModel : ViewModel() {
// StateFlow: current lap count — always available, latest value
private val _lapCount = MutableStateFlow(0)
val lapCount: StateFlow<Int> = _lapCount.asStateFlow()
// SharedFlow: one-time pit stop alerts — only heard if you're tuned in
private val _pitStopAlert = MutableSharedFlow<String>()
val pitStopAlert: SharedFlow<String> = _pitStopAlert.asSharedFlow()
fun nextLap() {
_lapCount.value++
}
fun triggerPitStop(reason: String) {
viewModelScope.launch {
_pitStopAlert.emit("Pit stop! Reason: $reason")
}
}
fun reset() {
_lapCount.value = 0
}
}
A few things to notice here:
- We extend
androidx.lifecycle.ViewModeldirectly incommonMain— no platform-specific code needed. viewModelScopeis available out of the box and handles cancellation automatically on both platforms.StateFlowis backed by aMutableStateFlowwith an initial value of0.SharedFlowis backed by aMutableSharedFlowwith no replay (default).
Android Side: Jetpack Compose
Since PitStopViewModel now lives in commonMain and extends androidx.lifecycle.ViewModel, we use it directly in Compose — no wrapper needed.
// androidApp/PitStopScreen.kt
import androidx.compose.runtime.*
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun PitStopScreen(viewModel: PitStopViewModel = viewModel()) {
// StateFlow: collected as lifecycle-aware state
val lapCount by viewModel.lapCount.collectAsStateWithLifecycle()
// SharedFlow: collected as side-effect (LaunchedEffect)
var alertMessage by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
viewModel.pitStopAlert.collect { alert ->
alertMessage = alert
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Lap: $lapCount", style = MaterialTheme.typography.headlineLarge)
Spacer(modifier = Modifier.height(16.dp))
alertMessage?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
Button(onClick = { viewModel.nextLap() }) {
Text("Next Lap")
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.triggerPitStop("Tyre wear") }) {
Text("Trigger Pit Stop")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = { viewModel.reset() }) {
Text("Reset")
}
}
}
Key decisions here:
collectAsStateWithLifecycle()is used forStateFlow— it respects lifecycle and won’t collect in the background.LaunchedEffect(Unit)handlesSharedFlow— it collects events and updates local state. Using aSnackbarHostStateor aChannelare valid alternatives depending on your UX.
iOS Side: The Wrapper Pattern
This is where the real work happens. Swift can’t collect Kotlin Flows directly, so we write a thin wrapper in iosMain that subscribes to the Flow and calls back into Swift.
// iosMain/PitStopViewModelWrapper.kt
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class PitStopViewModelWrapper {
val viewModel = PitStopViewModel()
// Observe StateFlow: callback fires on every new value
fun observeLapCount(onChange: (Int) -> Unit) {
viewModel.lapCount
.onEach { onChange(it) }
.launchIn(viewModel.viewModelScope)
}
// Observe SharedFlow: callback fires on each event emission
fun observePitStopAlert(onAlert: (String) -> Unit) {
viewModel.pitStopAlert
.onEach { onAlert(it) }
.launchIn(viewModel.viewModelScope)
}
// IMPORTANT: clears the ViewModel and cancels viewModelScope
fun dispose() {
viewModel.clear()
}
}
The PitStopViewModelWrapper class is what Swift will interact with. It:
- Instantiates
PitStopViewModeldirectly — no manual scope needed - Uses
viewModel.viewModelScopefor launching collectors - Calls
viewModel.clear()for cleanup, which cancelsviewModelScopeinternally
Now the SwiftUI View:
// iosApp/PitStopView.swift
import SwiftUI
import shared // your KMP module name
class PitStopViewModelHolder: ObservableObject {
private let wrapper = PitStopViewModelWrapper()
@Published var lapCount: Int = 0
@Published var alertMessage: String? = nil
init() {
wrapper.observeLapCount { [weak self] count in
self?.lapCount = Int(count)
}
wrapper.observePitStopAlert { [weak self] alert in
self?.alertMessage = alert
}
}
func nextLap() {
wrapper.viewModel.nextLap()
}
func triggerPitStop() {
wrapper.viewModel.triggerPitStop(reason: "Tyre wear")
}
func reset() {
wrapper.viewModel.reset()
}
deinit {
wrapper.dispose()
}
}
struct PitStopView: View {
@StateObject private var holder = PitStopViewModelHolder()
var body: some View {
VStack(spacing: 16) {
Text("Lap: \(holder.lapCount)")
.font(.largeTitle)
if let alert = holder.alertMessage {
Text(alert)
.foregroundColor(.red)
}
Button("Next Lap") { holder.nextLap() }
.buttonStyle(.borderedProminent)
Button("Trigger Pit Stop") { holder.triggerPitStop() }
.buttonStyle(.bordered)
Button("Reset") { holder.reset() }
.buttonStyle(.bordered)
}
.padding()
}
}
The Swift ObservableObject pattern bridges Kotlin’s reactive model into SwiftUI’s state system:
- Kotlin callbacks update
@Publishedproperties - SwiftUI automatically re-renders when those properties change
deinitcallswrapper.dispose()to cancel the coroutine scope
The Gotchas You’ll Actually Hit
1. Threading: Always Use Dispatchers.Main
On iOS, UI updates must happen on the main thread. viewModelScope uses Dispatchers.Main by default, so you’re covered out of the box. However, if you ever launch coroutines with a custom scope somewhere in your code, make sure it’s also on Main:
// Safe — viewModelScope already uses Dispatchers.Main
viewModelScope.launch {
_pitStopAlert.emit("Pit stop!")
}
If you have CPU-heavy work, do it in withContext(Dispatchers.Default) inside the coroutine, but let the Flow emit back on Main.
2. Memory Leaks: Always Call dispose()
Since viewModelScope is managed by the ViewModel itself, cleanup is as simple as calling viewModel.clear() — which is what our dispose() does. Make sure it’s called from Swift’s deinit:
deinit {
wrapper.dispose()
}
3. SharedFlow Replay: Mind the Default
By default, MutableSharedFlow() has replay = 0. This means if a new collector subscribes after an emission, it misses that event entirely. This is intentional for one-shot events — but if you want new collectors to receive the last N events, configure replay:
private val _pitStopAlert = MutableSharedFlow<String>(replay = 1)
Use this sparingly. Replaying events like navigation commands on re-subscription is a common source of bugs.
4. StateFlow Distinctness on iOS
StateFlow skips duplicate emissions. If you call _lapCount.value = 5 twice in a row, the second emission is dropped. This is fine for Android, but worth understanding on iOS — your Swift callback simply won’t fire if the value hasn’t changed.
Wrapping Up
The lap is complete. Here’s what we covered:
StateFlowfor persistent UI state — always holds a value, new collectors get it immediatelySharedFlowfor events — fires and forgets, only active collectors receive it- The KMP bridge gap — Flows don’t natively cross to Swift
- A manual wrapper pattern in
iosMainusing callbacks - Android consumption with
collectAsStateWithLifecycle()andLaunchedEffect - iOS consumption with
ObservableObject,@Published, anddeinitcleanup - Real-world gotchas: threading, memory leaks, replay, and distinctness
The wrapper approach we used here isn’t the only solution — KMP-NativeCoroutines and SKIE both automate this boilerplate and are worth exploring as your project scales. But understanding the manual approach first gives you the mental model to debug anything those libraries abstract away.
In the next article, we’ll take this further by adding a Ktor-powered network layer to the shared module — one HTTP client, two platforms, zero duplicated logic.
Happy coding, and keep it flat out. 🏁
Joel · KMP Bits · Kotlin Multiplatform · Android · iOS