ViewModel, Lifecycle-Aware State & SavedStateHandle
The architecture cornerstone — survive configuration changes and process death correctly
Open interactive version (quiz + challenge)Real-world analogy
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
- What ViewModel is — ViewModel is a lifecycle-aware class that stores and manages UI-related data. It survives configuration changes (rotation, language switch, window resize). One ViewModel instance per scope (Activity or Fragment) — retrieved via ViewModelProvider or by viewModels() delegate. ViewModelStore holds the instance until the scope is permanently destroyed.
- ViewModel does NOT survive process death — When Android kills the process (low memory, user swipes away from recents), ViewModel is gone. On re-entry, a fresh ViewModel is created. Use SavedStateHandle for small UI state that must survive process death. Use Room/DataStore for large persistent data.
- ViewModelProvider and factory — val vm = ViewModelProvider(this)[MyViewModel::class.java] is the manual way. Hilt provides @HiltViewModel and by viewModels() which auto-wires the factory. Custom factories are needed when ViewModel has constructor parameters (pre-Hilt). ComponentActivity.viewModels() and Fragment.viewModels() are extension functions that combine both.
- viewModelScope — A CoroutineScope tied to ViewModel lifecycle — automatically cancelled when ViewModel.onCleared() is called. Use viewModelScope.launch { } for any async work started by the ViewModel. No manual cancellation needed. This prevents coroutine leaks when the user navigates away.
- Shared ViewModel between Fragments — by activityViewModels() retrieves a ViewModel scoped to the parent Activity — all Fragments sharing the same Activity get the same instance. Used for Fragment-to-Fragment communication without direct references. Also works with NavGraph-scoped ViewModels: by navGraphViewModels(R.id.nav_graph).
- SavedStateHandle — A key-value store injected into ViewModel (via constructor) that survives process death. Backed by the same Bundle mechanism as onSaveInstanceState. get(key) retrieves values, set(key, value) saves them. getLiveData(key) and getStateFlow(key) expose values as observable streams that update when state changes.
- SavedStateHandle for UI state — Store only small, parcelable/serializable values: selected item ID, filter string, sort order, form input. NOT for large lists or complex objects — those belong in Room or a repository cache. The system automatically serializes SavedStateHandle contents into the saved state bundle.
- MutableStateFlow in ViewModel — private val _uiState = MutableStateFlow(UiState.Loading) exposes val uiState: StateFlow = _uiState.asStateFlow(). StateFlow always has a current value, replays the last emission to new collectors, and is the modern replacement for LiveData inside ViewModels.
- ViewModel onCleared() — Called when the ViewModel is permanently destroyed — scope is done, no config change pending. Override to cancel non-coroutine resources: close database cursors, unregister callbacks, cancel third-party async tasks. viewModelScope coroutines are cancelled automatically before this.
- UiState sealed class pattern — sealed class UiState { object Loading : UiState(); data class Success(val data: T) : UiState(); data class Error(val message: String) : UiState() } — ViewModel exposes one StateFlow, UI renders based on sealed class variant. Eliminates boolean flag soup (isLoading, hasError, isEmpty).
- ViewModel as single source of truth — All user actions go to ViewModel (via functions or a Channel), all UI state comes from ViewModel (StateFlow). The View never talks to the Repository directly. This makes the ViewModel independently testable with a fake repository and no Android framework dependencies.
- Process death simulation — Test process death in Android Studio: run the app, navigate to the screen, use 'adb shell am kill ' or the 'terminate process' button in the Logcat toolbar, then navigate back via Recents. The system restores from saved state — verify your SavedStateHandle keys restore the correct UI.
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. 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. @HiltViewModel and @Inject constructor enable Hilt to auto-wire the factory. savedStateHandle is injected automatically by Hilt — no manual factory needed.
- 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. 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. 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. onSearchQueryChanged writes directly to savedStateHandle[KEY_SEARCH] — the StateFlow from getStateFlow() automatically emits this new value, triggering the combine chain.
- 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. 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- ViewModel Overview — Android Developers (Android Developers)
- SavedStateHandle — Android Developers (Android Developers)
- Kotlin flows on Android (Android Developers)
- A safer way to collect flows from Android UIs (Medium / Android Developers)
- UI Layer — Android App Architecture (Android Developers)