Lesson 15 of 83 intermediate

ViewModel, Lifecycle-Aware State & SavedStateHandle

The architecture cornerstone — survive configuration changes and process death correctly

Open interactive version (quiz + challenge)

Real-world analogy

A ViewModel is like a hotel concierge. Guests (Activities/Fragments) come and go — they check in, check out, switch rooms (rotate) — but the concierge stays at their desk holding all your requests and preferences. When a completely new guest arrives (process death + restore), the concierge has a special notebook (SavedStateHandle) given by the hotel management (system) that notes down what the previous guest wanted, so the new guest gets the right service immediately.

What is it?

ViewModel is the architecture component that survives configuration changes and holds UI state. viewModelScope handles coroutine lifecycle automatically. SavedStateHandle bridges the gap between configuration-change survival (ViewModel) and process-death survival (Bundle). The canonical pattern is: ViewModel exposes StateFlow, UI collects and renders it, user interactions call ViewModel functions.

Real-world relevance

In a school management app, a StudentListViewModel fetches student data from a Room database via a repository, exposes it as StateFlow, and handles filter/search state via SavedStateHandle (so the search query survives both rotation and process death). viewModelScope launches the database query. Multiple Fragments (list + filter panel) share the ViewModel via by activityViewModels(). The ViewModel is tested with a FakeStudentRepository that returns test data without any Android framework involvement.

Key points

Code example

// UiState model
sealed class StudentListUiState {
    object Loading : StudentListUiState()
    data class Success(
        val students: List<Student>,
        val totalCount: Int,
        val filteredCount: Int
    ) : StudentListUiState()
    data class Error(val message: String) : StudentListUiState()
}

// ViewModel with SavedStateHandle + viewModelScope
@HiltViewModel
class StudentListViewModel @Inject constructor(
    private val studentRepository: StudentRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    // Small UI state in SavedStateHandle — survives process death
    private val searchQuery = savedStateHandle.getStateFlow(KEY_SEARCH, "")
    private val selectedGrade = savedStateHandle.getStateFlow(KEY_GRADE, ALL_GRADES)

    private val _uiState = MutableStateFlow<StudentListUiState>(StudentListUiState.Loading)
    val uiState: StateFlow<StudentListUiState> = _uiState.asStateFlow()

    init {
        // Combine filters and load data reactively
        viewModelScope.launch {
            combine(searchQuery, selectedGrade) { query, grade -> query to grade }
                .debounce(300)  // Avoid querying on every keystroke
                .collectLatest { (query, grade) ->
                    loadStudents(query, grade)
                }
        }
    }

    fun onSearchQueryChanged(query: String) {
        savedStateHandle[KEY_SEARCH] = query  // Auto-persisted to SavedStateHandle
    }

    fun onGradeFilterChanged(grade: String) {
        savedStateHandle[KEY_GRADE] = grade
    }

    private suspend fun loadStudents(query: String, grade: String) {
        _uiState.value = StudentListUiState.Loading
        studentRepository.getStudents(query, grade)
            .catch { e -> _uiState.value = StudentListUiState.Error(e.message ?: "Unknown error") }
            .collect { students ->
                _uiState.value = StudentListUiState.Success(
                    students = students,
                    totalCount = studentRepository.getTotalCount(),
                    filteredCount = students.size
                )
            }
    }

    override fun onCleared() {
        super.onCleared()
        // viewModelScope cancelled automatically
        // Release any non-coroutine resources here
    }

    companion object {
        private const val KEY_SEARCH = "search_query"
        private const val KEY_GRADE = "selected_grade"
        private const val ALL_GRADES = "ALL"
    }
}

// Fragment collecting StateFlow
class StudentListFragment : Fragment(R.layout.fragment_student_list) {

    private val viewModel: StudentListViewModel by activityViewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    renderState(state)
                }
            }
        }

        binding.searchInput.doAfterTextChanged { text ->
            viewModel.onSearchQueryChanged(text?.toString() ?: "")
        }
    }

    private fun renderState(state: StudentListUiState) {
        when (state) {
            is StudentListUiState.Loading -> showLoading()
            is StudentListUiState.Success -> showStudents(state.students, state.filteredCount)
            is StudentListUiState.Error -> showError(state.message)
        }
    }
}

Line-by-line walkthrough

  1. 1. StudentListUiState is a sealed class with three variants — Loading, Success, Error. The UI renders exactly one of these states at a time, making impossible states (isLoading=true AND hasData=true) compile-time impossible.
  2. 2. @HiltViewModel and @Inject constructor enable Hilt to auto-wire the factory. savedStateHandle is injected automatically by Hilt — no manual factory needed.
  3. 3. savedStateHandle.getStateFlow(KEY_SEARCH, '') returns a StateFlow backed by the saved state. When savedStateHandle[KEY_SEARCH] = query is called, the StateFlow emits the new value AND it's persisted for process death survival.
  4. 4. combine(searchQuery, selectedGrade) merges two StateFlows into one — emitting a new pair whenever either flow emits. debounce(300) prevents database queries on every keystroke.
  5. 5. collectLatest cancels the previous loadStudents call if a new combined value arrives — essential for search: if the user types quickly, only the latest query reaches the database.
  6. 6. onSearchQueryChanged writes directly to savedStateHandle[KEY_SEARCH] — the StateFlow from getStateFlow() automatically emits this new value, triggering the combine chain.
  7. 7. viewLifecycleOwner.lifecycleScope.launch with repeatOnLifecycle(STARTED) is the gold-standard pattern for collecting StateFlow in Fragments — collection pauses when app goes to background (STOPPED) and resumes on foreground, preventing wasted emissions.
  8. 8. renderState uses a when expression on the sealed class — the compiler guarantees all cases are handled. Adding a new UiState variant without updating this when causes a compile error.

Spot the bug

class OrderViewModel(
    private val repository: OrderRepository
) : ViewModel() {

    // Bug 1
    val orders = MutableStateFlow<List<Order>>(emptyList())

    // Bug 2
    private val _context: Context

    init {
        _context = MyApp.instance  // Application context
        loadOrders()
    }

    fun loadOrders() {
        // Bug 3
        GlobalScope.launch {
            try {
                val result = repository.getOrders()
                orders.value = result
            } catch (e: Exception) {
                // silently ignored
            }
        }
    }

    fun refreshOrders() {
        loadOrders()
        loadOrders()  // Bug 4
        loadOrders()
    }
}
Need a hint?
Think about encapsulation, coroutine scoping, duplicate launches, and whether Application context in ViewModel is acceptable.
Show answer
Bug 1: orders is MutableStateFlow exposed directly as public — any class outside ViewModel can emit new values, breaking the single source of truth pattern. Fix: private val _orders = MutableStateFlow<List<Order>>(emptyList()); val orders: StateFlow<List<Order>> = _orders.asStateFlow(). Bug 2: Holding Application context is actually acceptable (Application lives as long as the process), but a regular Activity/Fragment Context would be a leak. The comment implies it's Application context — this is a grey area; for clarity, inject AndroidApplication via Hilt or avoid context in ViewModel entirely. Bug 3: GlobalScope.launch is NOT tied to the ViewModel lifecycle — if the ViewModel is cleared, this coroutine keeps running, wasting resources. Fix: viewModelScope.launch { }. Bug 4: refreshOrders calls loadOrders three times — launches 3 concurrent coroutines all writing to orders.value. Results race condition: whichever coroutine finishes last wins. Fix: cancel previous job before launching: private var loadJob: Job? = null; loadJob?.cancel(); loadJob = viewModelScope.launch { loadOrders() }.

Explain like I'm 5

A ViewModel is like a personal assistant who remembers everything about your work session. If you leave your desk and come back (rotate the phone), the assistant is still there with all your files. But if the office burns down (app process killed), even the assistant's memory is gone. SavedStateHandle is the assistant's emergency notebook — a tiny notepad they always carry that survives even if the building burns. When you rebuild the office, the assistant reads the notebook and picks up right where you left off.

Fun fact

ViewModel was introduced in 2017 partly because developers were storing data in static variables or Application class singletons to survive configuration changes — both terrible patterns that led to memory leaks and stale data. ViewModel provides the same survival but with proper scoping and automatic cleanup.

Hands-on challenge

Build a ProductSearchViewModel for a fintech app: (1) Accept a SearchRepository via Hilt injection. (2) Use SavedStateHandle for search query and selected category filter. (3) Expose StateFlow (Loading, Success with List and page info, Error). (4) Implement pagination — loadNextPage() appends to current list. (5) Write a unit test using a FakeSearchRepository that returns test data, verify initial state is Loading, then Success after fake data loads, without any Android framework.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery