A shared ViewModel has no lifecycle, so you have to give it one
androidx.lifecycle.ViewModel is Android's. The shared class needs an explicit clear() and two platforms that call it.
TL;DR
androidx.lifecycle.ViewModel gives you two things: a scope that is cancelled automatically, and
survival across configuration change. Neither is a language feature — both come from an Android
ViewModelStoreOwner.
A shared ViewModel in commonMain has neither. If you hand-roll one, you own clear(), and two
platforms have to call it at the right moment — which they define differently. Android destroys and
recreates on rotation; SwiftUI creates and discards views constantly and has no equivalent event.
There is now a multiplatform lifecycle-viewmodel artifact, and it is the right default. Knowing
what it is doing for you is what lets you debug it when a coroutine outlives a screen.
The case this post is about
A shared ViewModel, hand-rolled because the Android one is not available in commonMain:
// commonMain
open class SharedViewModel {
protected val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
open fun clear() = scope.cancel()
}
class ReadingViewModel(repo: ReadingRepository) : SharedViewModel() {
val state: StateFlow<ReadingState> = repo.items()
.map(::toState)
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), ReadingState.Loading)
}
Then each platform wires it up, which is where the two platforms diverge:
// Android — Compose
@Composable
fun ReadingRoute() {
val vm = remember { ReadingViewModel(get()) } // ← survives nothing, clears nothing
ReadingListContent(vm.state.collectAsState().value, …)
}
// iOS — SwiftUI
struct ReadingScreen: View {
@StateObject private var holder = VMHolder() // ← when does clear() run?
…
}
Both compile. Both work in the happy path. Both leak.
The symptom
Four behaviours, one per platform pair.
Android: a new ViewModel on every rotation. remember is keyed to composition, so rotation
produces a fresh instance — losing state and starting a second network fetch:
rotate device
GET /items (new ViewModel, new stateIn, new upstream)
previous ViewModel's scope: never cancelled
Android: scopes accumulating. Nothing calls clear(), so every rotation leaves a live
SupervisorJob with a WhileSubscribed upstream in its grace window:
rotate ×10 → 10 live scopes, 10 coroutines in the Recomposer's dispatcher
iOS: work continuing after the screen is gone. SwiftUI's @StateObject is deallocated when
the view's identity goes away, and deinit on a Swift holder is not a reliable hook for a Kotlin
object's teardown — particularly if a Kotlin closure captured the holder, which is a retain cycle
across the bridge.
And the one that cost the most time: state loss on process death. Neither platform was saving anything, so a backgrounded app resumed to a blank screen. The hand-rolled base class had no notion of saved state at all, and nothing in the code hinted that the problem existed.
Why the obvious fix failed
"Call clear() from a Compose DisposableEffect." Closest honest attempt, and it is wrong on
rotation:
DisposableEffect(Unit) { onDispose { vm.clear() } }
A configuration change disposes the composition. So this clears the ViewModel precisely when you wanted it to survive, converting a leak into state loss.
"Keep a global map of ViewModels by screen key." A hand-rolled ViewModelStore, which is
genuinely what is needed — and doing it yourself means owning the keying, the eviction, and the
"was this a rotation or a real exit" question that you have no signal for in commonMain.
"Use rememberSaveable for the state instead." Addresses state restoration for primitives and
not the scope lifetime, and it cannot hold the ViewModel itself.
"deinit on the Swift holder." Does not fire when a Kotlin closure holds the holder — the
cross-runtime retain cycle — so the cleanup is gated on the thing it is cleaning up.
The actual mechanism
Three separable responsibilities, usually bundled into the word "ViewModel".
Scope ownership. Something must cancel the coroutine scope exactly once, when the screen is
genuinely finished. viewModelScope on Android is cancelled by onCleared().
Survival across recreation. Something must hold the instance through a configuration change
and hand the same one back. That is the ViewModelStore, held by an Activity or a
NavBackStackEntry — the ViewModelStoreOwner.
Saved state across process death. Something must serialise a minimal snapshot and restore it.
That is SavedStateHandle, backed by the platform's saved-instance-state mechanism.
Only the first is portable. The second and third are owner concepts, and the two platforms have genuinely different owners:
Android has an explicit recreate-with-identity event, and a store that outlives it.
iOS has no configuration change in the Android sense. A SwiftUI view is created and discarded freely; identity is managed by SwiftUI, and the closest analogue to "finished" is the view's identity leaving the hierarchy.

A ViewModel's lifecycle is not a property of the ViewModel. It is a property of its owner, and
commonMainhas no owners. So either you supply one per platform, or you use the artifact that already does.
And the SwiftUI failure mode is worth being specific about, because it is silent in both
directions. A body is re-evaluated whenever SwiftUI decides to, so where the instance is held
decides which bug you get:
struct ArticleView: View {
let viewModel = ArticleViewModel() // new instance on every body evaluation:
// state resets, in-flight work orphaned
@StateObject var vm = ArticleWrapper() // created once per view IDENTITY — the owner
}
The first line never throws and never logs. It presents as a screen that intermittently forgets what the user typed, at a frequency determined by SwiftUI's diffing — which is to say, irreproducibly, and never on the simulator while you are watching.
The mirror image is holding it too long: a view model stored in an app-level object and keyed by
nothing is never cancelled, so its scope and its collectors outlive the screen. On Android that
would be a leaked viewModelScope and onCleared() would have caught it. On iOS nothing is going
to tell you — there is no onCleared() to not-get-called.
Which is the practical reading of the rule above: the per-platform work is not writing a ViewModel base class, it is deciding what counts as "this screen is finished" on a platform that does not have that event, and then making exactly one thing responsible for saying so.
The fix
Use the multiplatform lifecycle-viewmodel artifact, and understand the two things it is doing.
// build.gradle.kts
commonMain.dependencies {
implementation(libs.androidx.lifecycle.viewmodel) // multiplatform
implementation(libs.androidx.lifecycle.runtime.compose) // collectAsStateWithLifecycle
}
// commonMain — a real ViewModel, in shared code
class ReadingViewModel(
repo: ReadingRepository,
private val saved: SavedStateHandle,
) : ViewModel() {
val state: StateFlow<ReadingState> = repo.items()
.map(::toState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReadingState.Loading)
// survives process death; the scope does not need to
var scrollIndex: Int
get() = saved["scrollIndex"] ?: 0
set(v) { saved["scrollIndex"] = v }
override fun onCleared() { /* extra teardown, if any */ }
}
// commonMain — Compose, both platforms
@Composable
fun ReadingRoute() {
val vm: ReadingViewModel = viewModel() // resolved from the local store owner
val state by vm.state.collectAsStateWithLifecycle()
ReadingListContent(state, …)
}
Four things that resolves:
viewModelScope is cancelled exactly once, by onCleared(), when the owner says the screen is
finished — not when the composition is disposed. That is the rotation fix.
The instance survives recreation, because viewModelScope's owner is the store, not the
composition. One instance, one upstream, one fetch across rotations.
SavedStateHandle works on both platforms, which closes the process-death gap the hand-rolled
base class could not express.
And on iOS the owner is provided for you. Compose Multiplatform supplies a
ViewModelStoreOwner tied to the Compose root, so viewModel() resolves without Swift-side
plumbing — which is what removes the unreliable deinit hook.
If you must hand-roll — a non-Compose iOS UI, for instance — then be explicit about who owns the clear, and make it a handle rather than a destructor:
// commonMain — exported to a non-Compose iOS UI
class ReadingViewModelHandle(private val vm: ReadingViewModel) {
fun observe(onState: (ReadingState) -> Unit): Subscription = vm.observe(onState)
fun close() = vm.clear() // Swift calls this from onDisappear
}
Explicit close() called from the platform is honest about the ownership. A deinit that fires
only when a cycle is already broken is not.
How we proved it
One instance across rotations. A counting constructor plus a rotation loop:
// androidInstrumentedTest
@Test
fun rotationReusesTheSameViewModel() {
val first = scenario.withActivity { currentViewModel() }
scenario.recreate()
val second = scenario.withActivity { currentViewModel() }
assertSame(first, second)
}
before (remember): FAILED — two distinct instances
after (viewModel()): PASSED
No accumulating scopes. The direct measurement, because "it leaks" needs a number:
rotate ×10, then read live coroutine count in the ViewModel's scope
before: 10 live scopes, 10 WhileSubscribed upstreams
after: 1 live scope, 1 upstream
One fetch across rotations, which is the user-visible consequence:
rotate ×10
before: GET /items × 11
after: GET /items × 1
And clearing happens on a real exit, not a rotation — the assertion that distinguishes the two
cases the DisposableEffect attempt conflated:
@Test
fun clearedOnFinishButNotOnRecreate() {
scenario.recreate()
assertFalse(viewModel.isCleared) // rotation: must survive
scenario.moveToState(Lifecycle.State.DESTROYED)
assertTrue(viewModel.isCleared) // genuine exit: must clear
}
That test is the one worth keeping, because both halves failed in different earlier attempts: the
hand-rolled version failed the second, and the DisposableEffect version failed the first.
One limit: the rotation assertions are Android instrumented tests, because rotation is an Android
concept. The iOS half was verified by the same deinit-counter technique from the memory post —
navigate in and out ten times, confirm ten onCleared() calls — which is cruder but checks the
property that matters.
What this generalizes to
Lifecycle belongs to the owner, not the owned. A ViewModel, a connection, a cache, a subscription — none of them know when they are finished. Something above them does. When you port a component to a context with no owner, the lifecycle is the part that does not come with it.
Three bundled responsibilities will not port together. "ViewModel" means scope, survival, and saved state. The portable part is the one that needs no platform event; the rest needs an owner. Any framework abstraction worth porting is worth decomposing first.
"Dispose" and "finished" are different events. The composition being torn down is not the user leaving. Conflating them turns a leak into data loss, which is a worse bug — and the test that catches it has to assert both directions.
Explicit teardown beats a destructor you cannot trust. close() called by the platform is
visible and debuggable. A deinit or finaliser that depends on reference counting across a runtime
boundary is a hook that fires when it feels like it.
Related
- Kotlin/Native memory is not the JVM's, and a retain cycle crosses the bridge — why that deinit never fired
- A Flow is cold, and that is why your ViewModel fires three network calls — the upstream that was being duplicated
- One failed child cancelled the whole screen, and the scope was the reason — what viewModelScope's job is doing
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".