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 objectUserListState holds all UI state in one place
  • MutableStateFlow + asStateFlow() — mutable internally, read-only externally
  • update { } — thread-safe state modifications
  • viewModelScope — 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")
    }
}

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 SavedStateHandle only in Android-specific code
  • For important state, persist to DataStore or database

Source Code

The KMP tutorial project is on GitHub:

View source code on GitHub →

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.