Let's Make ViewModels Work: A Look Inside ViewModel.kt

Let's break down the actual source of ViewModel.kt — why it's abstract, how it survives rotation, and how viewModelScope quietly saves you from leaking network calls.

Let's Make ViewModels Work: A Look Inside ViewModel.kt

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:

Kotlin
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#

A ViewModel 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#

ViewModel as the single source of truth

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.

Kotlin
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.

Kotlin
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.