
🧑🏻💻 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
combinefor 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
mergefor 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 : Android・AndroidStudio・Developmemt・JetpackCompose・Kotlin・Newbie