Sharing ViewModels in Kotlin Multiplatform

AndroidKotlin
Share on LinkedIn

The product team wanted identical checkout logic on Android and iOS. Duplicating validation rules failed twice before we moved CheckoutViewModel into commonMain with a single CheckoutUiState data class. Android wired it through viewModel(); iOS held the same instance in a ObservableViewModel wrapper. Bug fixes shipped once.

Shared ViewModels are viable in modern KMP thanks to androidx lifecycle multiplatform support. Business logic, state reduction, and coroutine scope management live in common code; platforms only render state and forward intents.

Dependencies

// commonMain
commonMain.dependencies {
    implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
    implementation("org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.4")
}

Align versions with Kotlin and Compose Multiplatform BOM.

Shared ViewModel

// commonMain
data class CheckoutUiState(
    val items: ImmutableList<LineItem>,
    val total: Money,
    val submitting: Boolean = false,
    val error: String? = null
)

class CheckoutViewModel(
    private val repository: CheckoutRepository
) : ViewModel() {

    private val _state = MutableStateFlow(CheckoutUiState(emptyList(), Money.ZERO))
    val state: StateFlow<CheckoutUiState> = _state.asStateFlow()

    fun submit() {
        viewModelScope.launch {
            _state.update { it.copy(submitting = true, error = null) }
            repository.submitOrder()
                .onSuccess { /* navigate via event */ }
                .onFailure { e ->
                    _state.update { it.copy(submitting = false, error = e.message) }
                }
        }
    }
}

Android integration

@Composable
fun CheckoutRoute(
    viewModel: CheckoutViewModel = viewModel { CheckoutViewModel(AppGraph.checkoutRepository) }
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    CheckoutScreen(state = state, onSubmit = viewModel::submit)
}

Use Koin or manual factory in viewModel { }.

iOS integration

Hold ViewModel at screen scope:

// iosMain helper
class CheckoutController(
    repository: CheckoutRepository
) {
    val viewModel = CheckoutViewModel(repository)
}

Swift with SKIE or NativeCoroutines:

struct CheckoutView: View {
    @StateViewModel var viewModel = CheckoutController(repository: ...).viewModel

    var body: some View {
        let state = viewModel.state.value
        // render state
    }
}

Exact Swift API depends on coroutines bridge choice—pick one per project.

One-shot events

Avoid navigation state in UiState when possible. Use Channel or sharedFlow for events:

private val _events = Channel<CheckoutEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()

// after success
_events.send(CheckoutEvent.NavigateToConfirmation(orderId))

UI collects events in LaunchedEffect / Swift task and consumes once.

SavedStateHandle

For process death on Android and equivalent on iOS where supported:

class DetailViewModel(
    savedStateHandle: SavedStateHandle,
    private val repo: ItemRepository
) : ViewModel() {
    private val itemId: String = savedStateHandle["itemId"] ?: error("id required")
}

Pass arguments through platform navigation into SavedStateHandle factory.

Testing shared ViewModels

@Test
fun submitFailure() = runTest {
    val vm = CheckoutViewModel(FailingRepository())
    vm.submit()
    advanceUntilIdle()
    assertNotNull(vm.state.value.error)
    assertFalse(vm.state.value.submitting)
}

No Android framework needed—ViewModel test artifacts work in commonTest.

Pitfalls

Process death on Android

SavedStateHandle survives process death; iOS has no equivalent—persist critical state to repository or local store on iOS when user backgrounds during multi-step flows.

Document platform differences in feature specs so QA tests both behaviors.

What to measure after rollout

Track error rates, tail latency, and resource utilization for two weeks after changes land—most regressions appear under real traffic mixes, not in staging smoke tests. Keep a rollback path documented: feature flags, Helm revision, or Git revert with known good digest. Review on-call pages tied to the topic quarterly; delete alerts that never fire and add thresholds that would have caught your last incident.

Run a short blameless postmortem if production surprised you, even for minor issues. The goal is updating this runbook section with one concrete lesson per quarter so the next engineer inherits context, not just configuration snippets.

Documentation your team should maintain

Maintain a one-page runbook link from your main service README: prerequisites, owner rotation, last drill date, and known sharp edges. Link to vendor docs in the Resources section below but capture org-specific decisions (CIDR ranges, cluster names, approval gates) in internal docs that stay current. New hires should deploy a safe canary within a week using only that runbook—if they cannot, the doc is incomplete.

Pre-production checklist

Before promoting to production, walk through this list with someone who was not the primary author—fresh eyes catch assumptions.

If any item is "we will do that later," treat it as a release blocker for tier-1 services.

Common questions from reviewers

Reviewers and auditors often ask whether this approach scales with team growth and whether it fails safely. Answer explicitly in your design doc: what happens when dependencies are down, when credentials expire, and when traffic doubles overnight. Prefer defaults that deny or degrade gracefully over defaults that fail open. Document known limits (throughput ceilings, supported versions, regions) in the same place operators look during incidents—avoid scattering critical constraints across Slack threads.

Version and compatibility notes

Pin library and control-plane versions in production manifests; track upstream release notes quarterly. Run upgrade drills in non-production before bumping minor versions that touch serialization, auth, or CRD schemas. Keep a compatibility matrix in your internal wiki listing supported Kubernetes, broker, and SDK versions validated together.

Resources

Frequently asked questions

Can I use Android ViewModel in commonMain?

Yes, with androidx.lifecycle ViewModel KMP artifacts. ViewModel, viewModelScope, and SavedStateHandle have multiplatform versions. Place feature ViewModels in commonMain and collect state from Android Compose and iOS SwiftUI.

How does iOS observe Kotlin ViewModel state?

Expose StateFlow or MutableStateFlow from the ViewModel. Swift uses SKIE, KMP-NativeCoroutines, or manual collectors to bridge Flow to ObservableObject. State updates drive SwiftUI views the same way as on Android.

Where should navigation logic live in KMP?

Keep navigation platform-specific—Compose Navigation on Android, SwiftUI NavigationStack on iOS. ViewModels emit one-shot events or state flags; UI layers translate to routes. Shared navigation abstractions often over-couple platforms.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →