On Android, ViewModels hold UI state and survive configuration changes. On iOS, ObservableObjects do the same job. Two platforms, two patterns, two codebases.
But the logic is often identical. Loading a user list, handling errors, managing form state — the business logic does not change between platforms. Only the UI does.
With KMP, you can write your ViewModel once in commonMain and use it on both Android and iOS. Since androidx.lifecycle:lifecycle-viewmodel version 2.8, ViewModel officially supports Kotlin Multiplatform.
How It Works
commonMain: SharedViewModel (StateFlow + business logic)
↓
androidMain: Compose collects StateFlow directly
iosMain: SwiftUI observes via wrapper or SKIE
Your ViewModel lives in shared code. Each platform connects to it using its native UI framework.
Setup
Dependencies
# gradle/libs.versions.toml
[versions]
lifecycle = "2.10.0"
[libraries]
lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" }
lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
// shared/build.gradle.kts
sourceSets {
commonMain.dependencies {
api(libs.lifecycle.viewmodel)
}
}
// composeApp/build.gradle.kts
dependencies {
implementation(libs.lifecycle.viewmodel.compose)
}
Use api instead of implementation for the shared module — this exports the ViewModel class to iOS through the framework.
Step 1: Create a Shared ViewModel
// shared/src/commonMain/kotlin/viewmodel/UserListViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class UserListState(
val users: List<User> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
class UserListViewModel(
private val userRepository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow(UserListState())
val state: StateFlow<UserListState> = _state.asStateFlow()
init {
loadUsers()
}
fun loadUsers() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val users = userRepository.getUsers()
_state.update { it.copy(users = users, isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(error = e.message, isLoading = false) }
}
}
}
fun deleteUser(userId: String) {
viewModelScope.launch {
try {
userRepository.deleteUser(userId)
_state.update { current ->
current.copy(
users = current.users.filter { it.id != userId }
)
}
} catch (e: Exception) {
_state.update { it.copy(error = e.message) }
}
}
}
fun clearError() {
_state.update { it.copy(error = null) }
}
}
This is a standard ViewModel — viewModelScope, StateFlow, update. The difference: it lives in commonMain, not in your Android module.
Key Patterns
- Single state object —
UserListStateholds all UI state in one place MutableStateFlow+asStateFlow()— mutable internally, read-only externallyupdate { }— thread-safe state modificationsviewModelScope— coroutine scope tied to ViewModel lifecycle
Step 2: Connect to Compose (Android)
On Android, using the shared ViewModel is identical to using a regular Android ViewModel:
// composeApp/src/androidMain/kotlin/ui/UserListScreen.kt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun UserListScreen(
viewModel: UserListViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when {
state.isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
state.error != null -> {
ErrorScreen(
message = state.error!!,
onRetry = { viewModel.loadUsers() }
)
}
else -> {
LazyColumn {
items(state.users) { user ->
UserItem(
user = user,
onDelete = { viewModel.deleteUser(user.id) }
)
}
}
}
}
}
collectAsStateWithLifecycle() observes the StateFlow and recomposes when it changes. Works exactly like any Android ViewModel.
Step 3: Connect to SwiftUI (iOS)
SwiftUI cannot observe Kotlin’s StateFlow directly. You need a wrapper that bridges the two worlds.
Option A: Manual Wrapper
// iosApp/iosApp/ObservableViewModel.swift
import SwiftUI
import shared
class ObservableUserListViewModel: ObservableObject {
let viewModel: UserListViewModel
@Published var state: UserListState
private var collector: Closeable?
init(viewModel: UserListViewModel) {
self.viewModel = viewModel
self.state = viewModel.state.value as! UserListState
// Collect StateFlow using a Flow collector helper
// Note: Kotlin StateFlow does NOT conform to Swift AsyncSequence
// Use a FlowCollector wrapper (see KMP Tutorial #15) or SKIE library
collector = FlowCollectorCompanion.shared.collect(flow: viewModel.state) { [weak self] value in
DispatchQueue.main.async {
self?.state = value as? UserListState ?? .loading
}
}
}
deinit {
collector?.close()
}
func loadUsers() {
viewModel.loadUsers()
}
func deleteUser(userId: String) {
viewModel.deleteUser(userId: userId)
}
}
// iosApp/iosApp/UserListView.swift
import SwiftUI
struct UserListView: View {
@StateObject private var observable: ObservableUserListViewModel
init(viewModel: UserListViewModel) {
_observable = StateObject(
wrappedValue: ObservableUserListViewModel(viewModel: viewModel)
)
}
var body: some View {
Group {
if observable.state.isLoading {
ProgressView()
} else if let error = observable.state.error {
VStack {
Text(error)
Button("Retry") {
observable.loadUsers()
}
}
} else {
List(observable.state.users, id: \.id) { user in
Text(user.name)
.swipeActions {
Button(role: .destructive) {
observable.deleteUser(userId: user.id)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
.navigationTitle("Users")
}
}
Option B: SKIE (Recommended for Production)
SKIE by Touchlab automatically generates Swift-friendly wrappers for Kotlin flows. With SKIE, you can use collect(flow:) directly in SwiftUI without writing manual wrappers.
// shared/build.gradle.kts
plugins {
id("co.touchlab.skie") version "0.10.0"
}
SKIE converts Kotlin StateFlow into Swift’s AsyncSequence, making the integration seamless.
A More Complex Example: Form ViewModel
Here is a shared ViewModel that handles form input, validation, and submission:
// shared/src/commonMain/kotlin/viewmodel/CreateNoteViewModel.kt
data class CreateNoteState(
val title: String = "",
val content: String = "",
val isSaving: Boolean = false,
val titleError: String? = null,
val saved: Boolean = false
)
class CreateNoteViewModel(
private val noteRepository: NoteRepository
) : ViewModel() {
private val _state = MutableStateFlow(CreateNoteState())
val state: StateFlow<CreateNoteState> = _state.asStateFlow()
fun onTitleChanged(title: String) {
_state.update {
it.copy(
title = title,
titleError = if (title.isBlank()) "Title is required" else null
)
}
}
fun onContentChanged(content: String) {
_state.update { it.copy(content = content) }
}
fun save() {
val current = _state.value
if (current.title.isBlank()) {
_state.update { it.copy(titleError = "Title is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isSaving = true) }
try {
noteRepository.createNote(
title = current.title,
content = current.content
)
_state.update { it.copy(isSaving = false, saved = true) }
} catch (e: Exception) {
_state.update { it.copy(isSaving = false) }
}
}
}
}
This pattern works on both platforms without changes. The ViewModel handles:
- Input state (title, content)
- Validation (title required)
- Async operations (saving)
- Success/error states
Shared ViewModel with Koin
If you followed KMP Tutorial #9: Koin, wire up your ViewModels like this:
// shared/src/commonMain/kotlin/di/ViewModelModule.kt
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val viewModelModule = module {
viewModelOf(::UserListViewModel)
viewModelOf(::CreateNoteViewModel)
viewModelOf(::SettingsViewModel)
}
Then in Compose:
@Composable
fun UserListScreen(
viewModel: UserListViewModel = koinViewModel()
) {
// Koin creates the ViewModel with all dependencies
}
Tips for Shared ViewModels
Keep ViewModels Platform-Agnostic
// BAD — Android-specific code in shared ViewModel
class MyViewModel : ViewModel() {
fun saveToFile(context: Context) { } // Context is Android-only
}
// GOOD — use abstractions
class MyViewModel(
private val fileStorage: FileStorage // interface in commonMain
) : ViewModel() {
fun saveToFile(content: String) {
viewModelScope.launch {
fileStorage.save(content)
}
}
}
Use a Single State Object
// BAD — multiple StateFlows are hard to manage
class MyViewModel : ViewModel() {
val users = MutableStateFlow<List<User>>(emptyList())
val isLoading = MutableStateFlow(false)
val error = MutableStateFlow<String?>(null)
}
// GOOD — one state, one source of truth
data class MyState(
val users: List<User> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
class MyViewModel : ViewModel() {
private val _state = MutableStateFlow(MyState())
val state: StateFlow<MyState> = _state.asStateFlow()
}
Handle the ViewModel Lifecycle
On Android, ViewModels survive configuration changes (rotation). On iOS, they do not — there is no equivalent. Keep this in mind:
- Do not rely on ViewModel surviving rotation in shared code
- Use
SavedStateHandleonly in Android-specific code - For important state, persist to DataStore or database
Source Code
The KMP tutorial project is on GitHub:
Related Tutorials
- KMP Tutorial #9: Koin — inject dependencies into your shared ViewModels
- KMP Tutorial #8: DataStore — persist ViewModel state across app restarts
- KMP Tutorial #3: Project Structure — understand commonMain where ViewModels live
- Compose Tutorial #9: ViewModel — Android ViewModel basics
What’s Next?
In the next tutorial, we will learn about Clean Architecture in KMP — how to structure your shared code into proper layers (domain, data, UI) so your project stays maintainable as it grows.
See you there.