ViewModel and Compose: The Perfect Architecture Partnership
Why ViewModel Still Matters in Compose
Some developers new to Compose wonder if ViewModel is still necessary. The answer is absolutely yes. While Compose manages UI state excellently, ViewModel serves a different purpose: surviving configuration changes (like screen rotation), hosting business logic, and serving as the single source of truth for screen-level state.
The combination of ViewModel + Compose creates a clean separation: ViewModel owns and manages state, Compose observes and renders it.
StateFlow: The Bridge Between ViewModel and Compose
StateFlow is the recommended way to expose state from ViewModel to Compose. It is hot, always has a value, and integrates seamlessly with Compose:
class ProfileViewModel : ViewModel() {
// Private mutable state
private val _uiState = MutableStateFlow(ProfileUiState())
// Public immutable state exposed to UI
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun updateName(name: String) {
_uiState.update { current ->
current.copy(name = name)
}
}
fun loadProfile(userId: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val profile = repository.getProfile(userId)
_uiState.update {
it.copy(
name = profile.name,
email = profile.email,
isLoading = false
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(error = e.message, isLoading = false)
}
}
}
}
}
data class ProfileUiState(
val name: String = "",
val email: String = "",
val isLoading: Boolean = false,
val error: String? = null
)Collecting State in Compose
Use collectAsStateWithLifecycle to safely collect StateFlow in Compose. It automatically stops collection when the lifecycle is below a certain state:
@Composable
fun ProfileScreen(
viewModel: ProfileViewModel = viewModel()
) {
// Lifecycle-aware collection - stops when app goes to background
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ProfileContent(
uiState = uiState,
onNameChange = viewModel::updateName,
onRetry = { viewModel.loadProfile(userId) }
)
}
@Composable
fun ProfileContent(
uiState: ProfileUiState,
onNameChange: (String) -> Unit,
onRetry: () -> Unit
) {
when {
uiState.isLoading -> LoadingScreen()
uiState.error != null -> ErrorScreen(uiState.error, onRetry)
else -> ProfileForm(uiState, onNameChange)
}
}One-Time Events: The Event Pattern
Navigation, snackbars, and other one-time events need special handling. Use a Channel or SharedFlow:
class CheckoutViewModel : ViewModel() {
private val _events = Channel<CheckoutEvent>()
val events = _events.receiveAsFlow()
fun checkout() {
viewModelScope.launch {
try {
val orderId = repository.placeOrder()
_events.send(CheckoutEvent.NavigateToConfirmation(orderId))
} catch (e: Exception) {
_events.send(CheckoutEvent.ShowError(e.message ?: "Unknown error"))
}
}
}
}
sealed class CheckoutEvent {
data class NavigateToConfirmation(val orderId: String) : CheckoutEvent()
data class ShowError(val message: String) : CheckoutEvent()
}
// In Compose
@Composable
fun CheckoutScreen(viewModel: CheckoutViewModel, navController: NavController) {
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is CheckoutEvent.NavigateToConfirmation -> {
navController.navigate("confirmation/${event.orderId}")
}
is CheckoutEvent.ShowError -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
}
}
}
// ... rest of UI
}Testing ViewModel with Compose
ViewModels can be tested independently of Compose, making your architecture more testable:
class ProfileViewModelTest {
private lateinit var viewModel: ProfileViewModel
private lateinit var fakeRepository: FakeProfileRepository
@Before
fun setup() {
fakeRepository = FakeProfileRepository()
viewModel = ProfileViewModel(fakeRepository)
}
@Test
fun loadProfile_success_updatesState() = runTest {
// Given
fakeRepository.setProfile(Profile("John", "john@example.com"))
// When
viewModel.loadProfile("123")
// Then
val state = viewModel.uiState.value
assertEquals("John", state.name)
assertEquals("john@example.com", state.email)
assertFalse(state.isLoading)
assertNull(state.error)
}
}