Skip to content
intermediate Phase 5 · Jetpack Libraries

ViewModel

Store UI-related data across configuration changes with ViewModel and ViewModelProvider.

45m
3 problems
Topic Progress 0%

The Configuration Change Problem

Why ViewModel Exists

When Android destroys and recreates an Activity or Fragment due to a configuration change (screen rotation, locale change, dark mode toggle), any state held in the UI layer is lost. The naive fix — saving and restoring state in onSaveInstanceState — works for small data but breaks down for complex objects, network requests, or database cursors.

ViewModel solves this by outliving the UI it is scoped to. It is retained during configuration changes and only cleared when the UI is permanently finished (e.g., user presses Back).

Lifecycle Relationship

Activity/Fragment created
  └─ ViewModel created (first time only)
      └─ UI observes ViewModel state

Configuration change occurs
  └─ Activity/Fragment destroyed
  └─ ViewModel survives (still alive)
  └─ New Activity/Fragment created
  └─ UI re-attaches to existing ViewModel

Activity finished (Back press)
  └─ ViewModel.onCleared() called
  └─ ViewModel destroyed

This lifecycle makes ViewModel the right place for:

  • UI state (screen data)
  • Business logic
  • Data loading and caching
  • Network request orchestration

ViewModel in Compose

With Jetpack Compose, ViewModel integrates directly via the viewModel() or hiltViewModel() function:

class OrderViewModel(private val repo: OrderRepository) : ViewModel() {
    private val _uiState = MutableStateFlow(OrderUiState())
    val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()

    fun loadOrder(orderId: String) {
        viewModelScope.launch {
            _uiState.value = OrderUiState(isLoading = true)
            val order = repo.getOrder(orderId)
            _uiState.value = OrderUiState(order = order)
        }
    }
}

@Composable
fun OrderScreen(viewModel: OrderViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when {
        uiState.isLoading -> CircularProgressIndicator()
        uiState.order != null -> OrderContent(uiState.order!!)
        else -> ErrorMessage()
    }
}

The ViewModel is scoped to the Activity or NavBackStackEntry. Multiple composables in the same screen share the same instance.

ViewModelProvider and Factories

How ViewModel Is Created

ViewModelProvider is the mechanism that creates and retrieves ViewModel instances. It ensures only one instance exists per scope.

In Compose, viewModel() or hiltViewModel() calls ViewModelProvider internally. When you need to pass constructor arguments, you use a factory.

ViewModelFactory

class OrderViewModel(private val orderId: String, private val repo: OrderRepository) : ViewModel() {
    private val _uiState = MutableStateFlow(OrderUiState())
    val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()

    init {
        loadOrder()
    }

    private fun loadOrder() {
        viewModelScope.launch {
            _uiState.value = OrderUiState(isLoading = true)
            _uiState.value = OrderUiState(order = repo.getOrder(orderId))
        }
    }

    class Factory(private val orderId: String, private val repo: OrderRepository) : ViewModelProvider.Factory {
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            if (modelClass.isAssignableFrom(OrderViewModel::class.java)) {
                @Suppress("UNCHECKED_CAST")
                return OrderViewModel(orderId, repo) as T
            }
            throw IllegalArgumentException("Unknown ViewModel class")
        }
    }
}

Using the Factory in Compose

@Composable
fun OrderScreen(orderId: String) {
    val repository = LocalOrderRepository.current
    val viewModel: OrderViewModel = viewModel(
        factory = OrderViewModel.Factory(orderId, repository)
    )
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // ... render UI
}

viewModelScope

viewModelScope is a coroutine scope bound to the ViewModel's lifecycle. Coroutines launched here are automatically cancelled in onCleared(). This prevents leaks from long-running work.

class SearchViewModel(private val repo: SearchRepository) : ViewModel() {
    private val _results = MutableStateFlow<List<SearchResult>>(emptyList())
    val results: StateFlow<List<SearchResult>> = _results.asStateFlow()

    fun search(query: String) {
        viewModelScope.launch {
            _results.value = repo.search(query)
        }
    }
}

Never use GlobalScope in a ViewModel — coroutines there survive the ViewModel and cause leaks.

UI State Sealed Interfaces

Modeling UI State

Production apps should model screen state as a single sealed interface or data class. This avoids null checks, impossible states, and makes the UI deterministic:

sealed interface OrderUiState {
    data object Loading : OrderUiState
    data class Success(val order: Order) : OrderUiState
    data class Error(val message: String) : OrderUiState
}

The composable renders based on the state variant:

@Composable
fun OrderScreen(viewModel: OrderViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when (uiState) {
        is OrderUiState.Loading -> {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
        }
        is OrderUiState.Success -> {
            OrderContent(order = (uiState as OrderUiState.Success).order)
        }
        is OrderUiState.Error -> {
            ErrorMessage(message = (uiState as OrderUiState.Error).message)
        }
    }
}

One-Time Events

StateFlow replays the latest value. For one-time events (navigation, showing a snackbar), use a Channel or SharedFlow with replay = 0:

class OrderViewModel : ViewModel() {
    private val _events = Channel<OrderEvent>(Channel.BUFFERED)
    val events = _events.receiveAsFlow()

    fun confirmOrder() {
        viewModelScope.launch {
            _events.send(OrderEvent.NavigateToConfirmation)
        }
    }
}

sealed interface OrderEvent {
    data object NavigateToConfirmation : OrderEvent
    data class ShowSnackbar(val message: String) : OrderEvent
}

Collect in the composable with LaunchedEffect:

@Composable
fun OrderScreen(viewModel: OrderViewModel = hiltViewModel()) {
    val snackbarHostState = remember { SnackbarHostState() }

    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is OrderEvent.NavigateToConfirmation -> { /* navigate */ }
                is OrderEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
            }
        }
    }
}

Quiz

1. What happens to a ViewModel during a configuration change?

Question 1 options

2. When is onCleared() called on a ViewModel?

Question 2 options

3. Why should you avoid using GlobalScope in a ViewModel?

Question 3 options

4. What is the purpose of a ViewModelFactory?

Question 4 options

Flashcards

Question

Why does ViewModel survive configuration changes?

Answer

ViewModel is scoped to the ViewModelStore, which is retained by the Activity across configuration changes. The Activity is destroyed and recreated, but the ViewModelStore persists.

Question

What is viewModelScope and why use it?

Answer

A CoroutineScope bound to a ViewModel's lifecycle. Coroutines launched here are automatically cancelled when the ViewModel is cleared, preventing leaks.

Question

What is the correct way to handle one-time events in a ViewModel?

Answer

Use a Channel or SharedFlow with replay = 0 instead of StateFlow. Collect in LaunchedEffect in the composable.

Question

When should you use a ViewModel instead of remember?

Answer

When state must survive configuration changes, spans multiple composables at screen level, or holds complex business logic that should outlive the UI.

Revision Notes

Key Takeaways

  • 1. ViewModel survives configuration changes by being scoped to the ViewModelStore
  • 2. viewModelScope auto-cancels coroutines when the ViewModel is cleared
  • 3. Model UI state as a sealed interface with Loading/Success/Error variants
  • 4. Use Channel or SharedFlow for one-time events, not StateFlow
  • 5. Always use a ViewModelFactory when you need constructor arguments

Interview Tips

  • Explain the ViewModel lifecycle relative to the Activity lifecycle
  • Discuss why GlobalScope is dangerous in ViewModels
  • Be ready to implement a ViewModelFactory for constructor injection
  • Explain how to handle one-time navigation events without stale state replay

Cheat Sheet

ViewModel Cheat Sheet

Lifecycle:

  • Created on first access, survives config changes
  • onCleared() called when scope is permanently destroyed
  • Scoped to Activity, Fragment, or NavBackStackEntry

Creation:

  • viewModel() for no-arg constructors
  • viewModel { Factory(...) } for constructor arguments
  • hiltViewModel() with Hilt/Dagger injection

Coroutine Scope:

  • viewModelScope — auto-cancels on onCleared()
  • Use viewModelScope.launch { ... } for async work
  • Never use GlobalScope

State Pattern:

  • Single sealed interface for UI state (Loading/Success/Error)
  • StateFlow for state, Channel for one-time events
  • collectAsStateWithLifecycle() in Compose

Common Mistakes:

  • Holding Activity/Context reference in ViewModel (memory leak)
  • Using GlobalScope instead of viewModelScope
  • Storing UI state in LiveData when Compose works better with StateFlow