UI Event Patterns in UiState: 3 Approaches

1. Single Event (nullable)
sealed interface UiEvent {
data class ShowSnackbar(val message: String) : UiEvent
data class NavigateToDetail(val id: String) : UiEvent
data class ShowDialog(val type: DialogType) : UiEvent
}
data class UiState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val event: UiEvent? = null,
)
// ViewModel
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun onSaveClicked() {
_uiState.update { it.copy(event = UiEvent.ShowSnackbar("Saved")) }
}
fun onEventConsumed() {
_uiState.update { it.copy(event = null) }
}
// UI
LaunchedEffect(uiState.event) {
when (val event = uiState.event) {
is UiEvent.ShowSnackbar -> {
snackbarHostState.showSnackbar(event.message)
viewModel.onEventConsumed()
}
is UiEvent.NavigateToDetail -> {
navController.navigate("detail/${event.id}")
viewModel.onEventConsumed()
}
is UiEvent.ShowDialog -> viewModel.onEventConsumed()
null -> Unit
}
}
- ✅ Simplest, single field, single consume path
- ✅ StateFlow only, no SharedFlow/Channel needed
- ✅ Easy to test (
uiState.value.event) - ❌ Concurrent events → last one wins
- ❌ Identical consecutive events may not re-trigger
LaunchedEffect(data class equality)
2. Queue + drop(1)
data class UiState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val eventQueue: List<UiEvent> = emptyList(),
)
// ViewModel
private fun sendEvent(event: UiEvent) {
_uiState.update { it.copy(eventQueue = it.eventQueue + event) }
}
fun onSaveClicked() {
sendEvent(UiEvent.ShowSnackbar("Saved"))
sendEvent(UiEvent.NavigateToDetail(savedId))
}
fun onEventConsumed() {
_uiState.update { it.copy(eventQueue = it.eventQueue.drop(1)) }
}
// UI
LaunchedEffect(uiState.eventQueue) {
val event = uiState.eventQueue.firstOrNull() ?: return@LaunchedEffect
when (event) {
is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
is UiEvent.NavigateToDetail -> navController.navigate("detail/${event.id}")
is UiEvent.ShowDialog -> { /* ... */ }
}
viewModel.onEventConsumed()
}
- ✅ Multiple simultaneous events supported, processed in order
- ✅ Still exhaustive
when(type-safe) - ❌ Missed consume call → queue stalls, blocking all later events
- ❌
drop(1)has no guarantee the consumed item matches the handled one (risk with duplicate values)
3. Queue + ID matching
data class QueuedEvent(
val id: Long = System.nanoTime(),
val event: UiEvent,
)
data class UiState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val eventQueue: List<QueuedEvent> = emptyList(),
)
// ViewModel
private fun sendEvent(event: UiEvent) {
_uiState.update { it.copy(eventQueue = it.eventQueue + QueuedEvent(event = event)) }
}
fun onSaveClicked() {
sendEvent(UiEvent.ShowSnackbar("Saved"))
sendEvent(UiEvent.NavigateToDetail(savedId))
}
fun onEventConsumed(id: Long) {
_uiState.update { state ->
state.copy(eventQueue = state.eventQueue.filterNot { it.id == id })
}
}
// UI
LaunchedEffect(uiState.eventQueue.firstOrNull()?.id) {
val queued = uiState.eventQueue.firstOrNull() ?: return@LaunchedEffect
when (val event = queued.event) {
is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
is UiEvent.NavigateToDetail -> navController.navigate("detail/${event.id}")
is UiEvent.ShowDialog -> { /* ... */ }
}
viewModel.onEventConsumed(queued.id)
}
- ✅ No misconsumption even with duplicate-content events
- ✅ Stronger against double-processing (key = id, not whole list)
- ❌ More boilerplate (wrapper class, id generation, filterNot)
- ❌ Overkill for most UI-only events
Comparison
| Single Event | Queue + drop(1) | Queue + ID | |
|---|---|---|---|
| Concurrent events | ❌ last wins | ✅ | ✅ |
| Type safety | ✅ | ✅ | ✅ |
| Missed consume | recovers on next event | queue stalls | queue stalls |
| Misconsumption risk | none | possible | none |
| Complexity | low | medium | medium–high |
| Best for | 1 event per screen | save →snackbar+navigate | payment/critical flows |
Progression:
start with Single Event
→ move to Queue when simultaneous events are a real requirement
→ add ID matching only for flows where lost/duplicated events are unacceptable.