Chanzmao ʕ•ᴥ•ʔ Bear Blog

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
    }
}

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()
}

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)
}

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.

#Android #Flow #Jetpack Compose