ViewModel & UI State Explained
ARCHITECTURE › Components
A ViewModel's whole job is to hold UI state that outlives a single Activity or Fragment instance. It does this by living in a ViewModelStore, an object owned by the ViewModelStoreOwner, your Activity or Fragment, that is itself held onto across the temporary destroy-and-recreate cycle a configuration change triggers. When you rotate the screen, change the system language, or fold into multi-window, the old Activity instance is destroyed and a new one is created, but ViewModelProvider hands that new Activity the exact same ViewModel instance rather than building a fresh one. Your state just keeps existing.
What a ViewModel does not survive is process death. If the OS kills your backgrounded app to reclaim memory, the entire process goes with it, ViewModelStore included, and every ViewModel in it is gone. When the user returns, Android may recreate the Activity and hand it a brand new ViewModel with no memory of what came before. Closing that specific gap is the job of a different tool entirely, which comes up shortly.
Because a ViewModel can outlive the Activity or Fragment that created it, potentially spanning several rotations, it must never hold a direct reference to a View, Activity, Fragment, or an Activity-scoped Context. If it did, that reference would keep the destroyed Activity pinned in memory. This is the classic ViewModel leak, and it trips people up because the mistake looks harmless: you pass the Activity into the constructor once, and the code compiles and even runs fine on the first screen. The leak only shows up under repeated rotation, when old Activity instances pile up in memory because something is still holding onto them, this ViewModel that is designed to survive the very recreation that destroyed them.
// BAD: this ViewModel now outlives and pins the destroyed Activity in memory
class BadViewModel(private val activity: Activity) : ViewModel()
When a ViewModel genuinely needs a Context, most commonly to read a string or dimension resource, the fix isn't to avoid Context entirely, it's to use the right one. The Application context lives for as long as the process does and is never destroyed out from under a ViewModel, so it can't produce the leak an Activity context does. There are two idiomatic ways to reach it. Extend AndroidViewModel, whose constructor is handed the Application directly, or, if you're building a ViewModel through a factory, pull APPLICATION_KEY out of CreationExtras instead of subclassing anything.
class ResourceViewModel(app: Application) : AndroidViewModel(app) {
fun title(): String = getApplication<Application>().getString(R.string.title)
}
Every ViewModel gets a built-in viewModelScope, a CoroutineScope backed by Dispatchers.Main.immediate plus a SupervisorJob. You launch background work on it instead of building and tracking your own scope. The payoff is automatic cleanup: when the framework calls onCleared() on the ViewModel, it cancels the Job backing viewModelScope, and cancelling a Job cancels every coroutine that was launched inside it. There is no need to manually collect a list of jobs and cancel them one by one, which is exactly the kind of bookkeeping that caused leaks in pre-coroutine Android code.
There's a second, less obvious effect of that cancellation worth knowing cold: once a CoroutineScope's Job has been cancelled, it stays cancelled. Any launch call made on that scope afterward does not throw, it simply does nothing, the new coroutine is created in a cancelled state and never runs its body. That's what makes onCleared() safe even if something tries to kick off more work on the ViewModel after teardown has started.
class DataViewModel(private val repo: DataRepository) : ViewModel() {
init {
viewModelScope.launch {
repo.observeItems().collect { /* update state */ }
// cancelled automatically when onCleared() runs, no manual bookkeeping
}
}
}
onCleared() is not called on a configuration change, that would defeat the entire purpose of ViewModel, and it is not called just because the app moves to the background either. It runs exactly once, when the ViewModelStoreOwner that owns this ViewModel is finished for good: the Activity calls finish() and isn't being recreated, or the Fragment is permanently removed from its container rather than merely replaced during a transient state. Confusing 'backgrounded' with 'destroyed for good' is a common mistake. An app backgrounded for hours, with onStop called repeatedly, never touches its ViewModels' onCleared() at all, because the ViewModelStoreOwner is still alive, just not visible.
class MyViewModel : ViewModel() {
override fun onCleared() {
super.onCleared()
// Runs once the ViewModelStoreOwner is gone for good, NOT on rotation,
// and NOT merely because the app was backgrounded
}
}
Screen state is almost always more than one flag, it's loading, or it's got content, or it failed with an error, and a UI can only render one of those at a time. The idiomatic shape for that is a sealed class or sealed interface with one case per state, so the UI's when block is exhaustive and the compiler catches a missed case. Converting a raw Result<T> into that shape is a small, mechanical step: success becomes Content, failure becomes Error carrying a message, falling back to a default message when the exception has none of its own.
To publish that state, keep a private MutableStateFlow and expose only a read-only StateFlow via asStateFlow(). The UI collects the public one but can only change it by calling a ViewModel method, never by writing into the flow directly. That single rule is what keeps the data flow one-directional: every state change has a name, a method the ViewModel defines, instead of being an arbitrary assignment from anywhere in the codebase. A loader built this way should also publish Loading the moment a fetch starts, before the result comes back, so the UI shows a spinner immediately instead of a stale or blank screen.
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Content<T>(val data: T) : UiState<T>
data class Error(val message: String) : UiState<Nothing>
}
class SearchViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState<List<Result>>>(UiState.Loading)
val uiState: StateFlow<UiState<List<Result>>> = _uiState.asStateFlow() // read-only for UI
fun load() {
_uiState.value = UiState.Loading
viewModelScope.launch {
_uiState.value = runCatching { repo.search() }
.fold(UiState::Content) { UiState.Error(it.message ?: "Something went wrong") }
}
}
}
Collecting a ViewModel's StateFlow inside a Composable has two common APIs, and the difference matters for battery and correctness, not just style. Plain collectAsState() collects for as long as the Composable is in composition, full stop, even while the screen is fully backgrounded and invisible to the user. collectAsStateWithLifecycle() is lifecycle-aware: it stops collecting once the host drops below STARTED, and resumes automatically when it comes back. That means work upstream, like a repository Flow doing database queries or making network calls, actually pauses when nobody can see the result, instead of quietly running forever in the background.
// PREFERRED: pauses collection once the host drops below STARTED
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// AVOID: keeps collecting even while the screen is fully backgrounded
val uiState by viewModel.uiState.collectAsState()
A StateFlow is built to replay its current value to every new collector, that's exactly what makes it good for screen state. It's exactly the wrong tool for a one-time signal like 'navigate to the next screen now.' If that flag is set inside a StateFlow<UiState>, it doesn't just fire once, it stays there as the current value, so the next recomposition, or the next collector after a rotation, sees it again and re-triggers the navigation the user already completed. The fix is a different primitive for events versus state: a Channel, consumed downstream as a Flow via receiveAsFlow(), delivers each value to exactly one collector exactly once, and then it's gone.
// BAD: StateFlow replays its latest value, navigation re-fires after rotation
_uiState.update { it.copy(navigateToHome = true) }
// GOOD: Channel delivers each event exactly once, then it's gone
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events: Flow<UiEvent> = _events.receiveAsFlow()
fun onLoginSuccess() {
viewModelScope.launch { _events.send(UiEvent.NavigateToHome) }
}
A repository often exposes a cold Flow, work only starts once something actually collects it. Returning that cold Flow straight out of a ViewModel pushes the decision of when to start and stop collecting onto the UI, which isn't where that decision belongs. stateIn converts a cold Flow into a hot StateFlow scoped to viewModelScope, and the SharingStarted policy controls when upstream collection actually runs. SharingStarted.WhileSubscribed(5000) is the common choice for UI state: it keeps collecting only while there's at least one active subscriber, and it holds on for a five second grace window after the last one disappears, exactly enough to survive a rotation without restarting the whole flow, but not so long that it wastes battery once the screen is truly gone.
class ItemViewModel(repo: ItemRepository) : ViewModel() {
val items: StateFlow<List<Item>> = repo.getItems() // cold Flow from the repository
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // 5s grace window for rotation
initialValue = emptyList()
)
}
A subtler bug shows up when a screen can trigger the same fetch twice in quick succession, a user double-tapping refresh, or a recomposition re-running a load call. Without any protection, two identical network calls fire concurrently, wasting bandwidth and risking two different Error or Content results racing to update the same state. The fix is a single-flight pattern: if a fetch for this key is already in progress, new callers should wait for and reuse its result instead of starting a second one. The usual implementation caches the in-flight work itself, typically as a Deferred from async, keyed by whatever identifies the request. A new call checks the cache first: if a Deferred is already running, await it, otherwise start one, store it, and clear the cache entry once it completes so the next genuinely new request isn't stuck reusing a stale result forever.
class SingleFlightLoader(private val scope: CoroutineScope) {
private var inFlight: Deferred<Data>? = null
suspend fun load(fetch: suspend () -> Data): Data {
val existing = inFlight
if (existing != null) return existing.await() // join the one already running
val deferred = scope.async { fetch() }
inFlight = deferred
return try {
deferred.await()
} finally {
inFlight = null // clear so the next call can start a fresh fetch
}
}
}
A ViewModel with a no-argument constructor, or one that only takes a SavedStateHandle, doesn't need a custom factory, the framework already knows how to build those. A ViewModelProvider.Factory becomes necessary the moment the constructor takes something the framework can't supply on its own: a repository instance, a manually constructed analytics client, or a value read from a custom CreationExtras key. There's one more case that looks like it needs a factory but doesn't: a class annotated @HiltViewModel with an @Inject constructor. Hilt generates the factory at compile time, so calling by viewModels() on a Hilt-annotated ViewModel just works, no factory code required at all.
// Hilt generates the factory at compile time, no ViewModelProvider.Factory needed
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepo: AuthRepository,
private val handle: SavedStateHandle
) : ViewModel()
// In the Fragment or Composable, this just works:
private val viewModel: LoginViewModel by viewModels()
When a hand-written factory is genuinely needed, the modern way to build one is the viewModelFactory DSL rather than implementing the ViewModelProvider.Factory interface directly. Inside its initializer block, whatever is needed gets read out of CreationExtras: APPLICATION_KEY for the Application, or createSavedStateHandle() for a properly wired SavedStateHandle. That last function matters specifically because constructing SavedStateHandle() directly gives an empty instance with no connection to the actual saved-state Bundle, it would silently lose everything on process death instead of restoring it. createSavedStateHandle() is what actually hooks into the real saved-state machinery.
val factory = viewModelFactory {
initializer {
val app = this[APPLICATION_KEY] as MyApp
val handle = createSavedStateHandle() // wired to real saved state
MyViewModel(app.repository, handle)
}
}
SavedStateHandle exists to close the one gap plain ViewModel fields can't cover: system-initiated process death. It's a key-value handle, injected into the ViewModel's constructor, that persists its values into the saved-state Bundle the OS restores after killing and recreating the process. Because it ultimately serializes to a Bundle, only Bundle-compatible values can go in: primitives, Strings, Parcelable, and Serializable. A live View, an open network call, or a lambda that closes over an Activity can't be put in there, they either crash at save time or silently fail to survive, because none of them can be meaningfully turned into bytes and restored later.
@Parcelize
data class Filter(val query: String, val sort: String) : Parcelable
// Safe: Parcelable serializes into the Bundle, survives process death
handle["filter"] = Filter("coffee", "rating")
// NOT safe: a View can't be put in a Bundle
// handle["view"] = myTextView // crashes at save time
Reading a SavedStateHandle value once in init and storing it in a val works for state that never changes, but most screen state does change, a search query the user keeps typing into. getStateFlow(key, default) is the tool for that: it returns a StateFlow that re-emits every time that key is written, seeded from whatever value already exists under that key, or the given default if there is none yet. Under the hood this behaves like a small map from key to its own MutableStateFlow. Reading a key that hasn't been seen before creates and seeds its flow from the current stored value or the default, and writing through the handle's set() function updates that same flow so every existing collector gets the new value.
That's a shape worth building by hand for something like a fake, test-only handle: a backing map from key to MutableStateFlow, where getStateFlow looks up or creates the entry, and set updates the existing entry's value rather than replacing the map slot, so anyone already collecting keeps seeing new values.
class FilterViewModel(private val handle: SavedStateHandle) : ViewModel() {
// Re-emits on every update to "query" AND survives process death
val query: StateFlow<String> = handle.getStateFlow("query", "")
fun onQueryChange(q: String) { handle["query"] = q }
}
// In Compose
val query by viewModel.query.collectAsStateWithLifecycle()
Put the two survival stories together and you get the question interviewers actually like to ask: a screen is rotated, and later, while backgrounded, the OS kills the process to reclaim memory, then the user returns and the screen is restored. No single mechanism covers both events by itself. The ViewModel instance itself handles the rotation, ViewModelProvider hands the recreated Activity the same instance, so nothing is lost there. But that same instance is gone entirely after process death, a brand new ViewModel gets created when the process restarts. Only a value stored in SavedStateHandle bridges that second gap, because it was written into the saved-state Bundle the OS persists and restores independently of whether the process survived.
class CounterViewModel(private val handle: SavedStateHandle) : ViewModel() {
// The ViewModel instance itself survives rotation.
// SavedStateHandle is what additionally survives process death.
val count: StateFlow<Int> = handle.getStateFlow("count", 0)
fun increment() { handle["count"] = (handle["count"] ?: 0) + 1 }
}
Scope decides both how long a ViewModel lives and who else can see the same instance. by viewModels() ties the ViewModel to its own Fragment or Activity, so two different Fragments each calling it get two separate instances, useless if they need to share state. by activityViewModels() instead scopes the lookup to the host Activity's own ViewModelStore, so any Fragment hosted in that Activity that calls it resolves the exact same instance, that's the idiomatic way to share state between sibling Fragments without passing data through arguments or a shared static field. hiltViewModel() follows the same idea in Compose Navigation: it defaults to scoping against the current NavBackStackEntry, so composables on the same destination share one instance, and a parent entry gets passed explicitly for a wider shared scope, such as across an entire nav graph.
// Fragment A
private val sharedVM: SharedViewModel by activityViewModels() // Activity-scoped
// Fragment B - resolves the SAME instance as Fragment A
private val sharedVM: SharedViewModel by activityViewModels()
// Separate instances per Fragment, no sharing:
// private val sharedVM: SharedViewModel by viewModels()