Clean ViewModels in Jetpack Compose
Jetpack Compose makes UI code feel smaller.
A screen can be a function. State can be collected as values. UI updates can be described instead of manually pushed into views.
That is a huge improvement.
But Compose does not automatically keep ViewModels clean.
A Compose ViewModel can still become the place where networking, navigation, analytics, persistence, formatting, permissions, and business rules all meet. When that happens, the UI may be declarative, but the feature is still hard to reason about.
The goal is not to make ViewModels empty.
The goal is to give them a smaller job.
#The Job of a Compose ViewModel
In most Compose features, I want a ViewModel to do three things:
- expose renderable UI state
- map application results into that state
- emit small screen events when the parent needs to react
That is already useful.
It gives the composable something simple to render. It gives tests a stable behavior to assert. It keeps business operations from disappearing into presentation code.
#The ViewModel That Becomes the Feature
This version is common:
@HiltViewModel
class ProductsViewModel @Inject constructor(
private val api: ProductsApi,
private val dao: ProductsDao,
private val analytics: FirebaseAnalytics,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _state = MutableStateFlow(ProductsUiState())
val state: StateFlow<ProductsUiState> = _state.asStateFlow()
fun loadProducts() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true)
try {
val response = api.products()
dao.insertAll(response.items.map(ProductEntity::from))
analytics.logEvent("products_loaded", null)
_state.value = ProductsUiState(
title = "${response.items.size} products",
products = response.items.map(ProductRowUiModel::from)
)
} catch (error: Throwable) {
analytics.logEvent("products_failed", null)
_state.value = ProductsUiState(
errorMessage = "Could not load products."
)
}
}
}
}
This is injectable. It is testable with mocks. It may even be fine in a tiny feature.
But the ViewModel is doing a lot:
- fetching from the network
- writing to the database
- deciding analytics events
- mapping API models into UI models
- deciding error copy
- owning loading state
When requirements change, many unrelated reasons to change point at the same class.
That is the smell.
#Use UI State as a Contract
Compose works best when the screen receives a value it can render.
data class ProductsUiState(
val title: String = "Products",
val isLoading: Boolean = false,
val products: List<ProductRowUiModel> = emptyList(),
val errorMessage: String? = null
)
data class ProductRowUiModel(
val id: ProductId,
val name: String,
val price: String
)
The composable can stay boring:
@Composable
fun ProductsScreen(
state: ProductsUiState,
onRetry: () -> Unit,
onProductClick: (ProductId) -> Unit
) {
when {
state.isLoading -> LoadingProducts()
state.errorMessage != null -> ErrorMessage(
message = state.errorMessage,
onRetry = onRetry
)
else -> ProductsList(
title = state.title,
products = state.products,
onProductClick = onProductClick
)
}
}
This screen does not know where products come from. It does not know about Retrofit, Room, Hilt, or analytics.
That is a good sign.
#Map Application Results Into State
For a larger feature, the ViewModel can receive use case output and turn it into UI state.
interface LoadProductsOutput {
fun productsLoadingStarted()
fun productsLoaded(products: List<Product>)
fun productsLoadingFailed()
}
class ProductsViewModel(
private val priceFormatter: ProductPriceFormatter
) : ViewModel(), LoadProductsOutput {
private val _state = MutableStateFlow(ProductsUiState())
val state: StateFlow<ProductsUiState> = _state.asStateFlow()
override fun productsLoadingStarted() {
_state.value = _state.value.copy(
isLoading = true,
errorMessage = null
)
}
override fun productsLoaded(products: List<Product>) {
_state.value = ProductsUiState(
title = "${products.size} products",
products = products.map { product ->
ProductRowUiModel(
id = product.id,
name = product.name,
price = priceFormatter.format(product.price)
)
}
)
}
override fun productsLoadingFailed() {
_state.value = ProductsUiState(
errorMessage = "Could not load products."
)
}
}
Now the ViewModel has a focused responsibility: presentation mapping.
It does not know whether loading products means a network call, a cache read, an offline fallback, or a migration between APIs. It receives meaningful results and maps them for the screen.
#Let the Route Connect Intents
In Compose, I like separating the stateless screen from the route that connects state, effects, and feature wiring.
@Composable
fun ProductsRoute(
viewModel: ProductsViewModel,
onLoadProducts: suspend () -> Unit,
onProductSelected: (ProductId) -> Unit
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
onLoadProducts()
}
ProductsScreen(
state = state,
onRetry = {
coroutineScope.launch {
onLoadProducts()
}
},
onProductClick = onProductSelected
)
}
The route is not business logic. It is the Compose boundary where UI state and UI intents meet.
The composed action can call a use case:
class ProductsFeatureFactory @Inject constructor(
private val loadProducts: LoadProductsUseCase,
private val priceFormatter: ProductPriceFormatter
) {
fun create(
onProductSelected: (ProductId) -> Unit
): ProductsRouteModel {
val viewModel = ProductsViewModel(priceFormatter)
return ProductsRouteModel(
viewModel = viewModel,
onLoadProducts = {
loadProducts.execute(output = viewModel)
},
onProductSelected = onProductSelected
)
}
}
data class ProductsRouteModel(
val viewModel: ProductsViewModel,
val onLoadProducts: suspend () -> Unit,
val onProductSelected: (ProductId) -> Unit
)
You may choose a different mechanical shape in a real Android app. Hilt may create the ViewModel. Navigation may own the route model. A parent feature may pass the actions.
The architectural direction is the important part:
Composable intent -> composed action -> use case -> ViewModel output -> UI state
The ViewModel does not have to trigger every operation itself.
#What About The Usual Hilt ViewModel?
Many Android teams write this:
@HiltViewModel
class ProductsViewModel @Inject constructor(
private val loadProducts: LoadProductsUseCase
) : ViewModel() {
fun onRetry() {
viewModelScope.launch {
loadProducts.execute()
}
}
}
That is not automatically wrong.
For small screens, it may be the simplest useful shape. The problem starts when the ViewModel becomes the home for operation policy, error interpretation, analytics, navigation, and state mapping at the same time.
When the feature grows, moving the operation trigger out of the ViewModel can make responsibilities clearer:
- the composable reports intent
- the route or parent feature connects the intent to an action
- the use case performs the operation
- the ViewModel maps the outcome into state
That is a tradeoff, not a religion.
#Keep Navigation Out of Leaf ViewModels
A ViewModel should rarely know the whole navigation graph.
class LoginViewModel @Inject constructor(
private val navController: NavController
) : ViewModel() {
fun loginDidSucceed() {
navController.navigate("home")
}
}
This couples the login screen to the app journey. It knows that success means home. It knows the route string. It becomes harder to reuse login inside onboarding, checkout, or account recovery.
A cleaner ViewModel emits a screen event:
sealed interface LoginScreenEvent {
data object LoginCompleted : LoginScreenEvent
}
class LoginViewModel : ViewModel() {
private val _events = Channel<LoginScreenEvent>()
val events = _events.receiveAsFlow()
fun loginDidSucceed() {
_events.trySend(LoginScreenEvent.LoginCompleted)
}
}
The parent decides what that event means:
@Composable
fun LoginRoute(
viewModel: LoginViewModel,
onLoginCompleted: () -> Unit
) {
LaunchedEffect(viewModel) {
viewModel.events.collect { event ->
when (event) {
LoginScreenEvent.LoginCompleted -> onLoginCompleted()
}
}
}
LoginScreen(/* state and intents */)
}
Now login can be reused in different flows without knowing where those flows go.
#Formatting Is Still Logic
Formatting can live in the ViewModel when it is simple.
But if formatting has policy, rules, localization, experiments, or business meaning, give it a name.
class ProductPriceFormatter @Inject constructor(
private val moneyFormatter: MoneyFormatter
) {
fun format(price: Money): String {
return moneyFormatter.format(price)
}
}
The ViewModel can use the formatter without becoming the owner of every display rule.
That keeps tests clearer too. You can test price formatting once and test the ViewModel's mapping separately.
#Test The ViewModel's Promise
ViewModel tests should protect what the ViewModel promises the screen.
@Test
fun `shows products after successful load`() {
val viewModel = ProductsViewModel(
priceFormatter = FakeProductPriceFormatter("$10")
)
viewModel.productsLoaded(
listOf(Product.keyboard)
)
assertEquals(
ProductsUiState(
title = "1 products",
products = listOf(
ProductRowUiModel(
id = Product.keyboard.id,
name = "Keyboard",
price = "$10"
)
)
),
viewModel.state.value
)
}
This test does not care whether products came from Retrofit, Room, a fake repository, or a use case with retry logic.
It cares that the ViewModel maps a meaningful result into renderable state.
#What You Should Remember
Compose makes rendering cleaner, but it does not automatically make feature boundaries cleaner.
A clean Compose ViewModel should usually expose state, map results, and emit small screen events.
Be careful when it starts owning networking, persistence, analytics, navigation, formatting policy, and business operations all at once.
That is how a ViewModel stops presenting the feature and quietly becomes the feature.