Let's break down the source code to understand how the ViewModel actually works under the hood, and why it's so important for modern Android architecture.
The Data Manager#
Right at the top of the file, we see the declaration:
public expect abstract class ViewModel
For those of us who aren't always fans of dealing with abstract classes, maybe this one is a surprise.
By making it abstract, the Android lifecycle system can securely hook into it and manage its creation and destruction behind the scenes.
"ViewModel's only responsibility is to manage the data for the UI. It should never access your view hierarchy or hold a reference back to the Activity or the Fragment."
A ViewModel should not know anything about the Android framework. If we are passing a Context or a UI element into it, we are likely creating a memory leak.
Surviving Configuration Changes#
The documentation tells us that a ViewModel is always created in association with a scope, like an Activity or Fragment — but here comes the cool part:
"A ViewModel will not be destroyed if its owner is destroyed for a configuration change (e.g. rotation). The new owner instance just re-connects to the existing model."
If the user rotates their phone, the OS destroys and recreates the Activity. If we were holding our data inside the Activity, it would be wiped out. Because the ViewModel lives outside that immediate lifecycle — which deserves a post of its own — it survives the rotation.
When the UI comes back to life, it simply reconnects to the ViewModel, and all data is exactly where we left it.
State and Observability#
The core purpose of the ViewModel is to acquire and hold the state required by our UI. It acts as the single source of truth.
While the documentation mentions LiveData, in modern Android development we typically expose this information using Kotlin's StateFlow. When the state in the ViewModel changes, the UI automatically reacts and recomposes.
The Coroutines Connection#
If we scroll further down in ViewModel.kt, we find an extension property: viewModelScope.
public val ViewModel.viewModelScope: CoroutineScope
Remember our last post about coroutines? The viewModelScope is a built-in CoroutineScope tied directly to the ViewModel's lifecycle.
If the user closes the screen and the ViewModel is finally destroyed, any network calls or heavy background work running in the viewModelScope are automatically cancelled.
This prevents your app from crashing or wasting resources doing work for a screen that no longer exists.
A Modern Compose ViewModel#
To tie everything together, here is what a standard ViewModel looks like in a modern Jetpack Compose application.
This example uses a registration screen to show how we manage state, handle user actions, and trigger one-time events like navigation.
class RegisterViewModel(
private val authService: AuthService,
) : ViewModel() {
// 1. One-time events (navigation, toasts, snackbars)
private val eventChannel = Channel<RegisterEvent>()
val events = eventChannel.receiveAsFlow()
private var hasLoadedInitialData = false
// 2. The UI state
private val _state = MutableStateFlow(RegisterState())
val state = _state
.onStart {
if (!hasLoadedInitialData) {
observeValidationStates()
hasLoadedInitialData = true
}
}
.stateIn(
scope = viewModelScope,
// 3. The rotation superpower in action
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = RegisterState()
)
// 4. Single entry point for UI actions (MVI pattern)
fun onAction(action: RegisterAction) {
when (action) {
RegisterAction.OnLoginClick -> Unit
RegisterAction.OnRegisterClick -> register()
else -> Unit
}
}
private fun register() {
if (!validateFormInputs()) return
// 5. Safe background work
viewModelScope.launch {
_state.update { it.copy(isRegistering = true) }
val email = state.value.emailTextState.text.toString()
val username = state.value.usernameTextState.text.toString()
val password = state.value.passwordTextState.text.toString()
authService
.register(
email = email,
username = username,
password = password
)
.onSuccess {
_state.update { it.copy(isRegistering = false) }
// Send a one-time event to navigate away
eventChannel.send(RegisterEvent.Success(email = email))
}
.onFailure { error ->
// Handle failure state here
}
}
}
}
Four details in that snippet are doing most of the work:
State vs. events. We use StateFlow for data that needs to persist (like the text in the email field) and Channel for one-time events (like navigating to the success screen). This prevents bugs where the app tries to navigate again after the user rotates the screen.
SharingStarted.WhileSubscribed(5_000L). Remember our rotation scenario? This line tells the flow to wait five seconds before cancelling. Since a screen rotation takes less than a second, the state flow stays alive — no unnecessary network calls, no data loss.
MVI architecture. Instead of the UI calling random methods inside the ViewModel, it sends an Action. This creates a clean, unidirectional data flow that is incredibly easy to test and debug.
Lifecycle safety. Because we use viewModelScope.launch for the registration network call, if the user closes the app during the request, the coroutine is safely and immediately cancelled.
If you take one thing from this post: the ViewModel isn't a place to put code that doesn't fit elsewhere. It's a lifecycle-aware state holder. Everything it owns should be state the UI needs, or work scoped to the time that UI exists.
By understanding what is actually happening inside ViewModel.kt, you can stop guessing how your architecture works and start building more robust, crash-free applications.
Let's make this work.