Skip to content
Go back

Crossing the Finish Line: StateFlow & SharedFlow in Kotlin Multiplatform

by KMP Bits

KMP Bits Cover

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:

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:

The One-Sentence Rule

Use StateFlow when your UI needs to know something. Use SharedFlow when 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:

  1. KMP-NativeCoroutines — a library that generates Swift-friendly wrappers automatically
  2. SKIE (by Touchlab) — another powerful option for Swift/Kotlin interop
  3. Manual wrapping — writing your own iosMain wrapper using DisposableHandle

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:


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:


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:

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:


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:

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


Share this post on:

Previous Post
Under the Hood: How Compose and SwiftUI Handle What Happens Off-Screen
Next Post
Master Compose Shared Element Transitions: A Smooth UI Journey