The ViewModel became the default answer to every Android state management question around 2019. Need to survive rotation? ViewModel. Need to share data between fragments? ViewModel. Need to breathe? Probably ViewModel.
After shipping several production apps, I think the default is wrong about half the time.
What ViewModels Are Actually For
The documented purpose is narrow: survive configuration changes and act as a communication layer between the UI and data layer. That's it.
The problem is that "communication layer" is vague enough to expand to fill any space you give it. In practice, ViewModels in most codebases I've seen have become:
- Business logic containers
- Navigation coordinators
- Event buses
- Side effect managers
- Sometimes, actual views
A ViewModel that's grown past 300 lines is usually doing three different jobs. That's a design problem, not a size problem.
The Case Against ViewModel-Per-Screen
The standard architecture guide suggests one ViewModel per screen. This encourages a pattern where the ViewModel holds all state for a screen, which works fine until screens get complex.
The failure mode is a ViewModel with 15 StateFlow properties, each representing a different piece of independently-updating UI state, all being combined in collectAsStateWithLifecycle calls scattered across the composable tree.
A better model for complex screens: decompose the screen into logical state domains, each with its own small state holder or use-case-scoped ViewModel.
// Instead of one God ViewModel:
class FeedViewModel : ViewModel() {
val feedState: StateFlow<FeedState>
val playerState: StateFlow<PlayerState>
val filterState: StateFlow<FilterState>
val paginationState: StateFlow<PaginationState>
// ...
}
// Prefer domain-scoped holders:
class FeedContentViewModel : ViewModel() { /* feed items only */ }
class FeedPlayerCoordinator : ViewModel() { /* player lifecycle only */ }
When Plain Classes Win
For UI state that doesn't need to survive rotation and doesn't need sharing across fragments, a plain class with a MutableStateFlow is less code and easier to test.
class SearchHandler(private val repo: SearchRepository) {
private val _query = MutableStateFlow("")
val results: StateFlow<List<Result>> = _query
.debounce(300)
.flatMapLatest { repo.search(it) }
.stateIn(/* scope from caller */)
fun onQueryChange(q: String) { _query.value = q }
}
This class has no Android dependency. It's testable with pure JUnit. It doesn't need a ViewModelFactory. Pass it a coroutine scope from the composable, or inject it via the ViewModel if rotation survival matters.
The Actual Rule
I've landed on a simple heuristic:
- Needs to survive rotation or be shared across fragments → ViewModel
- Scoped to a single composable subtree, no rotation concern → plain class or
rememberstate holder - Cross-cutting concern like auth or session → singleton or scoped DI component, definitely not a ViewModel
The framework gives you tools. None of them are universal. The mistake is applying any single pattern until the codebase bends around it.