Ending the Event Management Debate in ViewModel: The "MVI-style" Best Practice using StateFlow and Channel

 

🧑🏻‍💻 Introduction

When streaming data from a ViewModel to the UI, do you ever struggle with how to handle one-time events like "screen navigation" or "showing a Toast"?

It’s tempting to think, "Why not just combine everything into one state?" However, this often leads to a common pitfall: event re-emission bugs.

Today, I’ll introduce a robust, boilerplate-friendly design pattern: "State = combine / Effect = merge."

 

🧑🏻‍💻 1. Separating State from Effect

First, let’s categorize UI elements into two distinct types based on their behavior:

  • UiState (State): Represents the current look of the screen. It must always hold a "latest value" (e.g., loading flags, usernames, input fields).
  • UiEffect (Side Effect): Represents momentary occurrences. These should be processed once and then forgotten (e.g., navigation, error alerts, snackbars).

 

🧑🏻‍💻 2. ViewModel Implementation: Choosing between combine and merge

In the ViewModel, we use different operators depending on the nature of the data flow.


class UserProfileViewModel(private val repository: UserRepository) : ViewModel() {

    // --- [State] Synthesizing the latest state ---
    // We combine multiple sources (Loading, User data, etc.) 
    // to ensure the UI always has a consistent "single frame" of data.
    private val _isLoading = MutableStateFlow(false)
    val uiState: StateFlow<UserProfileState> = combine(
        _isLoading, 
        repository.userData // Flow<User>
    ) { loading, user ->
        UserProfileState(userName = user.name, isLoading = loading)
    }.stateIn(
        scope = viewModelScope, 
        started = SharingStarted.WhileSubscribed(5000), 
        initialValue = UserProfileState()
    )

    // --- [Effect] Integrating independent events ---
    // Use a Channel for one-shot events and merge them into a single flow 
    // to pipe everything through a single "event bus" to the UI.
    private val navigationEvents = Channel<UserProfileEffect.Navigate>()
    private val toastEvents = Channel<UserProfileEffect.ShowToast>()

    val uiEffect: Flow<UserProfileEffect> = merge(
        navigationEvents.receiveAsFlow(),
        toastEvents.receiveAsFlow()
    )

    fun onUpdateClick() {
        viewModelScope.launch {
            _isLoading.value = true
            if (repository.update()) {
                navigationEvents.send(UserProfileEffect.Navigate("home"))
            } else {
                toastEvents.send(UserProfileEffect.ShowToast("Update failed"))
            }
            _isLoading.value = false
        }
    }
}

Why differentiate them?

  • Why combine for State? The UI must always be consistent. Even if only one value changes, combine re-emits the set of all "latest values," preventing the UI from showing incomplete data.
  • Why merge for Effect? If you use combine for events, a simple update to a State (like a loading spinner) would trigger a re-emission of the previous navigation event. merge ensures that only the event that just happened gets triggered.

 

🧑🏻‍💻 3. Handling Events in the View (Compose)

On the UI side, we handle these flows using methods tailored to their specific lifecycles.


@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel) {
    // 1. Observe State: Automatically updates the UI and respects lifecycle
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // 2. Consume Effect: Use LaunchedEffect to handle events exactly once
    LaunchedEffect(viewModel.uiEffect) {
        viewModel.uiEffect.collect { effect ->
            when (effect) {
                is UserProfileEffect.Navigate -> navController.navigate(effect.route)
                is UserProfileEffect.ShowToast -> showToast(effect.message)
            }
        }
    }

    // 3. Render UI: Simply follow the uiState
    ProfileContent(uiState) 
}

 

🧑🏻‍💻 Summary: Why this Pattern Wins

  • Unidirectional Data Flow (UDF): It clearly separates "State flowing down" from "Events flowing up."
  • Bug Prevention: It structurally prevents issues like "Toasts reappearing on screen rotation" or "double navigation."
  • Clean Code: The UI processes all events in a single when block, and the ViewModel keeps concerns neatly separated.

If you find your event management getting messy, give the State (combine) & Effect (merge) pattern a try!

👉 Android アーキテクチャの現在地:Google が推奨する UDF と、現場が選ぶ「MVI 風 MVVM」


Related Categories :  AndroidAndroidStudioDevelopmemtJetpackComposeKotlinNewbie