ViewModel and Compose: The Perfect Architecture Partnership

By Engineering Team • Published: 2025-12-15 • Updated: 2025-12-1515 min read

ViewModelArchitectureStateFlowMVVM

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)
    }
}
Note: Always use collectAsStateWithLifecycle() instead of collectAsState(). The lifecycle-aware version prevents unnecessary work when your app is in the background.

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)
    }
}
Note: Keep your Composables as dumb as possible - they should only render UI based on state. All logic belongs in ViewModel, where it can be easily tested.
← Back to Blog