MVVM & Unidirectional Data Flow Explained
ARCHITECTURE › Patterns
MVVM, Model-View-ViewModel, is Android's official architecture pattern, and interviewers use it as a proxy for whether you can structure a screen so it stays predictable as it grows. The pattern rests on one idea: unidirectional data flow. A screen's ViewModel produces an immutable snapshot of what the user should see, called UI state, and that state flows down to the View. The View never decides what to show on its own, it renders whatever state it's handed. When the user does something, a tap, a text change, that gets reported back up to the ViewModel as an event. The ViewModel processes the event and emits the next state, which flows back down. State down, events up, one direction each, no shortcuts.
@Composable
fun CounterScreen(count: Int, onIncrement: () -> Unit) {
Button(onClick = onIncrement) { Text("Count: $count") }
}
Here count is state coming down as a parameter, and onIncrement is an event lambda going up. CounterScreen never touches a counter variable itself, it just displays what it's given and reports taps.
The ViewModel holds UI state and the logic to update it, but it must never hold a reference to a View, Activity, Fragment, or Context. Two reasons drive this rule.
First, lifecycle: a ViewModel survives configuration changes like rotation and outlives the View it's paired with, so a stored View reference would leak the old, destroyed screen.
Second, testability: staying framework-free means the ViewModel can be unit tested on the plain JVM, no emulator, no Robolectric, just construct it and call functions.
// BAD: leaks the Activity across rotation
class BadViewModel : ViewModel() {
var activity: Activity? = null
}
// GOOD: no Android framework references
class GoodViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()
}
If the View is what the user sees, UI state is what the app says they should see, a complete, immutable snapshot of everything a screen needs to render itself. The recommended way to hold that snapshot is a single data class, not a handful of independent fields.
data class LoginUiState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null
)
Bundling everything into one object matters because a screen with separate fields, say one property for isLoading and another for errorMessage, can be observed mid-update: one field has changed and the other hasn't yet, and the View renders a combination that never should have existed. A single immutable object is always atomic. Every emission is one complete, self-consistent picture, which also makes debugging simpler, you log one object, and lets you derive computed properties like canSubmit straight from it. This is what people mean by 'single source of truth': one object, one place state lives, no field anywhere else that could quietly disagree with it.
The ViewModel exposes that immutable state through a pair of properties: a private, mutable backing property it writes to, and a public, read-only property everything else observes.
class CartViewModel : ViewModel() {
private val _uiState = MutableStateFlow(CartUiState())
val uiState: StateFlow<CartUiState> = _uiState.asStateFlow()
}
_uiState is a MutableStateFlow, only this class can call .value = or .update {} on it, because it's private. The public uiState is typed as a plain StateFlow, so the View can collect it but has no method available to push a new value in. That asymmetry is the whole mechanism behind 'only the owner mutates state', it isn't a convention you have to remember to follow, the compiler enforces it. If the View could write to state directly, you'd end up with two things that can both claim to be the current state, and no way to know which one is right. Compose State objects created with mutableStateOf follow the same idea when you're not using StateFlow: expose a State<T>, keep the MutableState<T> backing it private.
When the ViewModel needs to change one field of that state, it should never do a plain read-then-write.
// SAFE: atomic compare-and-set
_uiState.update { current -> current.copy(isLoading = true) }
// UNSAFE: two separate steps; a concurrent update in between gets lost
// _uiState.value = _uiState.value.copy(isLoading = true)
copy() builds a fresh instance of the data class with just the named field changed and everything else carried over unchanged, that's what keeps the object immutable. update {} applies that transform as a single atomic compare-and-set. Read .value, then assign a new .value in a second statement, and if another coroutine updates state in between those two steps, that write is silently overwritten and lost. This matters the moment more than one coroutine can touch the same ViewModel, for example two network callbacks completing close together, or a rapid double-tap firing two update calls before the first one lands.
'State down, events up' has a concrete shape in Compose: a stateless composable takes state as parameters and exposes events as lambdas, called state hoisting. It never owns or mutates business state itself.
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
TextField(value = query, onValueChange = onQueryChange)
}
@Composable
fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
SearchBar(query = state.query, onQueryChange = viewModel::onQueryChange)
}
SearchBar has no idea a ViewModel exists, it just renders query and calls onQueryChange on input, which makes it trivial to preview and test on its own. The caller, SearchScreen, is where the wiring happens, and it collects the ViewModel's StateFlow with collectAsStateWithLifecycle() rather than plain collectAsState(). That function ties collection to the lifecycle: it only collects while the screen is at least STARTED, so it automatically stops doing work when the app is backgrounded and resumes when it comes back, instead of wastefully collecting the whole time.
Loading, error, and empty aren't extra flags bolted onto a screen after the fact, they're part of what the state is describing, so they live inside the same immutable UI state class as everything else. Two shapes are common.
// Flags inside one data class
data class NewsUiState(
val isLoading: Boolean = false,
val articles: List<Article> = emptyList(),
val error: String? = null
)
// Sealed interface: exactly one case at a time
sealed interface NewsUiState {
data object Loading : NewsUiState
data class Success(val articles: List<Article>) : NewsUiState
data class Error(val message: String) : NewsUiState
}
The flags version is simpler to start with, but nothing stops isLoading from being true at the same time articles is already populated, an impossible combination the type system won't catch. The sealed version is stricter: only one case can exist at a time, a when over it is exhaustive, and 'loading with data already present' simply can't be represented. Either way, the state a screen exposes should have exactly one place a View looks to know what to render, never a mix of the UI state plus some other loading boolean living elsewhere.
Data usually arrives as a cold Flow from a repository, one that does nothing until something collects it. Turning that into the StateFlow a ViewModel exposes uses stateIn.
val uiState: StateFlow<NewsUiState> = repository.getArticles()
.map { articles -> NewsUiState(articles = articles) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsUiState(isLoading = true)
)
SharingStarted.WhileSubscribed(5_000) starts collecting the upstream flow when the first subscriber appears, and keeps it alive for five seconds after the last one disappears before cancelling it. That five-second grace period is deliberate: a screen rotation briefly drops to zero collectors and then immediately gets a new one, so the upstream work survives the rotation instead of restarting, but if the app is genuinely backgrounded for longer than that, the work actually stops. When a screen's state depends on more than one source, say a search query and a favorites set that can each change independently, you combine the flows before turning the result into a StateFlow, so a change to either input produces one recomputed state rather than two half-updated ones.
Not everything a ViewModel needs to tell the View is state to keep rendering, sometimes it's a one-off instruction: navigate to a detail screen, show a snackbar. It's tempting to fire those as a plain signal, but a plain emission can be missed if the View isn't collecting at that exact moment, and if it's re-delivered after a configuration change re-subscribes, it can fire a second time for something that already happened. The fix is to model the event as state the View reads once and then tells the ViewModel it has handled.
data class UiState(
val items: List<Item> = emptyList(),
val navigateToDetail: String? = null // null = not triggered
)
class ListViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()
fun onItemClicked(id: String) {
_uiState.update { it.copy(navigateToDetail = id) }
}
fun onNavigatedToDetail() {
_uiState.update { it.copy(navigateToDetail = null) } // consumed
}
}
The View observes navigateToDetail, navigates when it's non-null, then immediately calls onNavigatedToDetail() to clear it back to null. Because the event lives in state rather than a fire-and-forget stream, a later recomposition sees null and does nothing, so the same navigation can't accidentally replay.
Two different kinds of logic show up around a screen, and mixing them up is a common way UI state stops being trustworthy. Business logic implements product rules, whether an item is allowed to be bookmarked, whether a discount applies, and it belongs in the data or domain layer, independent of any particular screen. UI logic decides how state gets presented, formatting a timestamp, deciding whether to show a snackbar, and that belongs in the UI layer, the ViewModel and the View. The reason to keep them apart isn't tidiness for its own sake: business rules need to be correct wherever they're used, including from other screens or a background sync, so they shouldn't be trapped inside one ViewModel. Presentation decisions, on the other hand, are specific to how a particular screen chooses to show something, and don't belong leaking down into the domain layer. A ViewModel calling a domain use case for 'can this be bookmarked' and then separately formatting the result for display is doing both correctly, in two different places.
A ViewModel survives configuration changes like rotation, but it does not survive process death, when the system kills the app's process entirely to reclaim memory while it's in the background and later recreates it. Ordinary ViewModel fields are gone at that point, wiped along with the process. For small, critical pieces of state you want to survive that, like a half-typed search query, the tool is SavedStateHandle, which is backed by the same saved-instance-state bundle an Activity uses, and which the system restores when it recreates the process.
class SearchViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
val query: StateFlow<String> =
savedStateHandle.getStateFlow(key = "query", initialValue = "")
fun onQueryChange(newQuery: String) {
savedStateHandle["query"] = newQuery
}
}
getStateFlow reads and writes through the saved state bundle, so this behaves like any other StateFlow the View collects, but the value comes back after process death the same way it survives rotation. It's meant for small values, not a replacement for persisting real data to a database.
StateFlow fits as a UI state holder because of a specific set of guarantees Flow in general doesn't make. It always holds exactly one current value, readable at any time through .value. It requires an initial value at construction, there's no such thing as a StateFlow with nothing in it yet. And it's conflated: if you set the same value twice in a row, equal by equals(), it only emits once, collectors don't see a duplicate.
val stateFlow = MutableStateFlow(UiState()) // initial value required
val current: UiState = stateFlow.value // always readable
val sharedFlow = MutableSharedFlow<UiState>() // no initial value, no .value property
A plain SharedFlow has none of that: no current value to read synchronously, no required initial value, and by default no replay, a late collector sees nothing that happened before it subscribed. That's exactly right for one-off events you don't want replayed, but wrong for UI state, where a newly appearing collector, say a screen just rotated back in, needs to immediately see the current state rather than wait for the next change.
Put together, MVVM and unidirectional data flow give you a screen with exactly one place state lives, the ViewModel's immutable UI state, exactly one direction it travels, down to render, up as events, and exactly one thing allowed to change it, the ViewModel itself, enforced by exposing a private MutableStateFlow as a read-only StateFlow. Loading, error, and empty aren't special cases bolted on, they're just more fields inside that same state. Business rules live below the ViewModel in data and domain; how to present them lives in the ViewModel and View. If you're asked to defend this in an interview, the answer is really one sentence: a single source of truth flowing in one direction is what makes a screen's behavior predictable and testable, because there's never a second copy of state that could quietly disagree with the first.