ViewModel & UI State Explained

ARCHITECTURE › Components

A ViewModel is a class that holds UI state and survives **configuration changes**, screen rotation, locale change, entering multi-window, without your Activity or Fragment doing any extra work. The trick is where it lives: a ViewModel is stored in a ViewModelStore owned by the ViewModelStoreOwner (your Activity or Fragment), and that store survives the temporary destroy-and-recreate a config change triggers. When the system rotates the screen, the old Activity instance is destroyed and a new one created, but ViewModelProvider hands the new Activity the *same* ViewModel instance instead of building a fresh one.

What it does **not** survive is process death: if the OS kills your backgrounded app to reclaim memory, the whole process, and every ViewModel in it, disappears. That gap is what SavedStateHandle exists to close, more on that soon.

Because a ViewModel can outlive the Activity or Fragment that created it, spanning multiple rotations, it must **never hold a reference to a View, Activity, Fragment, or Activity-scoped Context**. If it did, that reference would keep the destroyed Activity alive in memory (a leak), because the ViewModel itself is retained across the very recreation that destroyed it.

// BAD: leaks the destroyed Activity across rotations
class BadViewModel(private val activity: Activity) : ViewModel()

If a ViewModel genuinely needs a Context, say to read a string resource, use the **Application context**, which lives as long as the process and can't be destroyed out from under you. Either extend AndroidViewModel(application), or pull APPLICATION_KEY out of CreationExtras in a factory.

Every ViewModel gets a built-in **viewModelScope**, a CoroutineScope backed by Dispatchers.Main.immediate plus a SupervisorJob. You launch work on it instead of building your own scope:

viewModelScope.launch {
    repo.observeItems().collect { items -> /* update state */ }
}

The payoff is automatic cleanup: when the framework calls onCleared() on the ViewModel, the SupervisorJob backing viewModelScope is cancelled, which cancels every coroutine launched in it. You never have to manually track and cancel jobs yourself, which is exactly the kind of leak that plagued pre-coroutine Android code.

To keep a screen's data flow one-directional, a ViewModel should never let the UI write directly into its state. The standard pattern: keep a **private** MutableStateFlow, and expose a **read-only** StateFlow via asStateFlow():

class SearchViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(SearchUiState())
    val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()

    fun onQueryChanged(q: String) {
        _uiState.update { it.copy(query = q) }
    }
}

The UI collects uiState (typically with collectAsStateWithLifecycle() in Compose) but can only change it by calling a method like onQueryChanged, never by assigning into the flow itself. That boundary is what makes state changes traceable, every mutation has to go through the ViewModel's own logic.

SavedStateHandle is a key-value handle, injected into the ViewModel's constructor, that persists small values to the saved-state Bundle the OS restores after **process death**, the gap plain in-memory ViewModel fields can't cover. Values must be Bundle-compatible: primitives, Parcelable, Serializable.

class CounterViewModel(private val handle: SavedStateHandle) : ViewModel() {
    val count: StateFlow<Int> = handle.getStateFlow("count", 0)
    fun increment() { handle["count"] = (handle["count"] ?: 0) + 1 }
}

getStateFlow(key, default) is the flow-friendly reader, it re-emits on every write to that key, so it can back UI state directly while also surviving process death.

You only need a custom **ViewModelProvider.Factory** when the ViewModel's constructor takes dependencies the framework can't supply on its own, a repository, an analytics client, or a key read from CreationExtras. The modern way to write one is the viewModelFactory DSL:

val factory = viewModelFactory {
    initializer {
        val app = this[APPLICATION_KEY] as MyApp
        val handle = createSavedStateHandle() // wired to saved state
        MyViewModel(app.repository, handle)
    }
}

createSavedStateHandle() builds a properly wired SavedStateHandle from the CreationExtras, constructing one directly (SavedStateHandle()) would give you an empty, disconnected instance instead.

hiltViewModel() is the Hilt-aware way to obtain a ViewModel in Compose, it looks up (or creates) a Hilt-injected instance scoped to the current ViewModelStoreOwner, provided the class is annotated @HiltViewModel with an @Inject constructor, no factory code required.

Scope is also what controls **sharing**. Two Fragments hosted in the same Activity that each call by viewModels() get *separate* instances. Switching to by activityViewModels() scopes the lookup to the Activity's own ViewModelStore, so both Fragments resolve the *same* instance, letting them share state. In Compose Navigation, hiltViewModel() defaults similarly to the current NavBackStackEntry, so composables on the same destination share one instance unless you pass a parent entry to scope wider.

Putting it together, three tools cover three different scopes of "remember this":

- **ViewModel**: in-memory state that survives configuration changes, gone on process death. - **SavedStateHandle**: small, ViewModel-owned values that also survive process death, IDs, queries, selections. - **rememberSaveable**: purely UI-layer Composable state (scroll position, expanded or collapsed) that a ViewModel never needs to read.

Large or complex data doesn't belong in any of these, that's what Room or DataStore are for. The question an interviewer is really asking, whichever wording they use, is: *does your business logic need this value, and does it need to survive process death?* Answer both and you land on the right tool.

Back to ViewModel & UI State