Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 091 / 100

Snackbars and dialogs in Compose: lessons from production

Feedback for users.

Expona Admin7 min read

Introduction

User feedback is the backbone of a polished mobile experience. Whether you're confirming a destructive action, showing a brief success message, or collecting a critical piece of information, snackbars and dialogs in Compose are your tools for clear communication. We've learned hard lessons in production about when to use each, how to thread state correctly, and where to avoid common traps that leave users confused or dialogs stuck behind loading spinners.

This article walks through the patterns we've refined across e-commerce checkouts, CRM dashboards, and trading platforms—practical approaches that keep your feedback mechanisms reliable and testable.

SnackbarHostState in Scaffold

The foundation of snackbar handling in Compose is the SnackbarHostState paired with a Scaffold. This combination gives you a centralized channel for transient messages without cluttering your composable hierarchy.

Here's how it works in practice:

kotlin
@Composable
fun OrderScreen() {
    val snackbarHostState = remember { SnackbarHostState() }
    
    Scaffold(
        snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
        topBar = { TopAppBar(title = { Text("Orders") }) }
    ) { innerPadding ->
        OrderList(
            modifier = Modifier.padding(innerPadding),
            snackbarHostState = snackbarHostState
        )
    }
}

By placing SnackbarHostState at the Scaffold level, you ensure only one snackbar displays at a time, and it won't be covered by floating action buttons or other UI elements. The state is scoped to the screen and survives recomposition when other UI properties change.

Pass the SnackbarHostState down to child composables that need it. This is simpler than creating a ViewModel just to hold snackbar messages, and it keeps the scope tight. For most screens, this pattern is sufficient.

Show from a LaunchedEffect

Snackbars should trigger from side effects, not directly from event handlers in onClick callbacks. Using LaunchedEffect ensures your message displays only when conditions are met and helps you avoid multiple copies of the same message.

Consider an e-commerce checkout where you delete an item from the cart:

kotlin
@Composable
fun CartScreen(viewModel: CartViewModel) {
    val snackbarHostState = remember { SnackbarHostState() }
    val uiState by viewModel.uiState.collectAsState()
    
    LaunchedEffect(uiState.itemDeletedId) {
        if (uiState.itemDeletedId != null) {
            snackbarHostState.showSnackbar(
                message = "Item removed",
                actionLabel = "Undo",
                duration = SnackbarDuration.Short
            )
            viewModel.clearItemDeletedId()
        }
    }
    
    Scaffold(snackbarHost = { SnackbarHost(hostState = snackbarHostState) }) { _ ->
        CartList(
            items = uiState.items,
            onDeleteItem = { viewModel.deleteItem(it) }
        )
    }
}

The LaunchedEffect watches uiState.itemDeletedId. When it changes, the snackbar shows. We clear the ID afterward to prevent showing the same message again if the composable recomposes. This separation of concerns—event in the ViewModel, side effect in the UI—makes the logic testable and predictable.

Another approach is to use a SnapshotFlow in your ViewModel if you need more control or are already using flows extensively:

kotlin
class CartViewModel : ViewModel() {
    private val _deleteItemEvent = Channel<String>(Channel.BUFFERED)
    val deleteItemEvent = _deleteItemEvent.receiveAsFlow()
    
    fun deleteItem(itemId: String) {
        // Perform delete
        viewModelScope.launch {
            _deleteItemEvent.send("Item removed")
        }
    }
}

@Composable
fun CartScreen(viewModel: CartViewModel) {
    val snackbarHostState = remember { SnackbarHostState() }
    
    LaunchedEffect(Unit) {
        viewModel.deleteItemEvent.collect { message ->
            snackbarHostState.showSnackbar(message)
        }
    }
    
    // Rest of composable
}

Channels are good for one-off events that shouldn't be replayed on recomposition. Choose based on your state management pattern.

AlertDialog for confirmations

For destructive or important actions—deleting an order, confirming a large transaction—use AlertDialog. Unlike snackbars, dialogs demand attention and should block interaction until resolved.

Here's a pattern from a trading platform where canceling an order needs confirmation:

kotlin
data class OrderUiState(
    val orders: List<Order> = emptyList(),
    val showCancelConfirm: Boolean = false,
    val orderToCancel: Order? = null,
    val isLoading: Boolean = false
)

@Composable
fun OrderDetailScreen(viewModel: OrderViewModel) {
    val uiState by viewModel.uiState.collectAsState()
    
    Scaffold { innerPadding ->
        OrderDetail(
            order = uiState.order,
            onCancelOrder = { viewModel.showCancelConfirm() },
            modifier = Modifier.padding(innerPadding)
        )
    }
    
    if (uiState.showCancelConfirm && uiState.orderToCancel != null) {
        AlertDialog(
            title = { Text("Cancel order?") },
            text = { Text("This cannot be undone.") },
            dismissButton = {
                TextButton(onClick = { viewModel.hideCancelConfirm() }) {
                    Text("No")
                }
            },
            confirmButton = {
                TextButton(
                    onClick = { viewModel.confirmCancel() },
                    enabled = !uiState.isLoading
                ) {
                    if (uiState.isLoading) {
                        CircularProgressIndicator(Modifier.size(16.dp))
                    } else {
                        Text("Yes, cancel")
                    }
                }
            },
            onDismissRequest = { viewModel.hideCancelConfirm() }
        )
    }
}

The dialog state lives in the ViewModel's UiState. When the user taps "Yes, cancel," we set isLoading to true, disable the button, and show a progress indicator. Once the network call finishes, we hide the dialog and show a snackbar confirming the cancellation.

This dual-feedback approach—dialog for confirmation, snackbar for result—gives users confidence that their action was received and processed.

Keep dialog state in the ViewModel when needed

While snackbars work well as ephemeral UI concerns, dialogs are persistent enough that their state should live in your ViewModel. This keeps dialogs in sync during config changes (rotation) and makes testing straightforward.

Here's a ViewModel pattern that handles multiple dialog scenarios:

kotlin
class OrderViewModel(private val orderRepository: OrderRepository) : ViewModel() {
    private val _uiState = MutableStateFlow(OrderUiState())
    val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()
    
    fun showCancelConfirm(order: Order) {
        _uiState.update { it.copy(showCancelConfirm = true, orderToCancel = order) }
    }
    
    fun hideCancelConfirm() {
        _uiState.update { it.copy(showCancelConfirm = false, orderToCancel = null) }
    }
    
    fun confirmCancel() {
        _uiState.update { it.copy(isLoading = true) }
        viewModelScope.launch {
            try {
                _uiState.value.orderToCancel?.let { order ->
                    orderRepository.cancelOrder(order.id)
                    _uiState.update { 
                        it.copy(
                            showCancelConfirm = false,
                            orderToCancel = null,
                            isLoading = false,
                            cancelSuccess = true
                        )
                    }
                }
            } catch (e: Exception) {
                _uiState.update { it.copy(isLoading = false) }
                // Show error snackbar via LaunchedEffect watching error state
            }
        }
    }
}

Rotate your device and the dialog stays open because its state survived the config change in the ViewModel. Write unit tests that verify showCancelConfirm is called, that isLoading flips, and that orderToCancel is cleared after a successful cancellation. No need to mock Compose UI.

Common mistakes

Showing snackbars in onClick callbacks. This can lead to multiple snackbars stacking if the user taps rapidly. Always route through state and LaunchedEffect.

Forgetting to dismiss dialogs after async operations. A user hits "confirm," the dialog stays open, the network call completes, but the dialog never closes because you forgot to set showDialog = false. Always update state to hide the dialog once the async task finishes, whether it succeeds or fails.

Keeping dialog state in the composable. Config changes reset it to false, and the user sees their dialog vanish mid-operation. Always store persistent dialog state in the ViewModel.

Overusing dialogs for simple messages. If the message is informational and doesn't require confirmation, use a snackbar. Dialogs interrupt user flow; reserve them for decisions.

Not disabling buttons during loading. Set enabled = !isLoading on confirm buttons. Otherwise, users tap multiple times, triggering duplicate requests. Show a progress indicator instead.

Key takeaways

  • Use SnackbarHostState in Scaffold for transient feedback; pass it to child composables that need it.
  • Trigger snackbars from LaunchedEffect watching ViewModel state, never from onClick handlers directly.
  • Use AlertDialog for confirmations and destructive actions; block interaction until the user decides.
  • Keep dialog state in the ViewModel so it survives config changes and is testable.
  • Show a progress indicator and disable the confirm button during async operations.
  • Combine dialogs and snackbars: confirm the action in a dialog, then show the result in a snackbar.
  • Test ViewModel methods that manage dialog state; you don't need UI tests for these logic paths.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles