Snackbar
Not every confirmation deserves an AlertDialog blocking the whole screen — telling
the user "Recipe saved!" after they tap Save is exactly the kind of low-stakes, transient
feedback a Snackbar is built for: it appears briefly near the bottom of the
screen, doesn't demand a response, and disappears on its own. Because showing one is an
asynchronous, timed event rather than a simple state flip, it's also our first real look at
triggering UI from a coroutine.
SnackbarHostState & SnackbarHost
A snackbar has two halves: SnackbarHostState, which holds the queue of messages
waiting to be shown, and SnackbarHost, the composable that actually renders
whatever is currently at the front of that queue. SnackbarHost is designed to slot
directly into Scaffold's snackbarHost parameter:
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { innerPadding ->
// screen content
}
rememberCoroutineScope() gives us a CoroutineScope tied to this
composable's lifecycle — we'll cover it properly in the Side Effects part of this track, but for
now: it's what lets a plain button click, which isn't itself a suspend function, launch a
suspending call like showSnackbar.
Showing a Snackbar From a Coroutine
SnackbarHostState.showSnackbar(...) is a suspend function — it
enqueues the message and suspends until the snackbar is dismissed (by timeout, swipe, or the
action button), which is why it must run inside a coroutine launched via scope.launch:
Button(onClick = {
saveRecipe(recipe)
scope.launch {
snackbarHostState.showSnackbar(message = "Recipe saved!")
}
}) {
Text("Save")
}
Worked Example: "Recipe saved!" With an Undo Action
showSnackbar returns a SnackbarResult once the snackbar is dismissed,
telling you whether the user tapped its action button (ActionPerformed) or it just
timed out / was swiped away (Dismissed). That return value is how RecipeBox offers
an "Undo" after deleting a recipe:
fun onDeleteRecipe(recipe: Recipe) {
recipes.remove(recipe)
scope.launch {
val result = snackbarHostState.showSnackbar(
message = "\"${recipe.title}\" deleted",
actionLabel = "Undo",
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
recipes.add(recipe)
}
}
}
Because recipes.remove(recipe) happens immediately and the coroutine only decides
whether to add it back afterward, the deletion feels instant to the user while still remaining
reversible for the few seconds the snackbar is visible.
Only One Snackbar at a Time
SnackbarHostState is intentionally a single-slot queue — if
showSnackbar is called again while one is already showing, the new call suspends
until the current snackbar finishes, then shows next. You never need to manually dismiss a
previous snackbar before showing another; just call showSnackbar again and let the
host state serialize them.