A dialog is state, not a function you call

Dialogs and bottom sheets are composables that exist conditionally, not imperative calls. Modelling them as state fixes the rotation bug, the double-show bug, and the question of where the result goes.

6 min read
androidcomposekotlinmaterial3

Day 39 — A dialog is state, not a function you call

Day 39 of 100. Coming from Views, this is the component whose model changed most: there is no show(), and looking for one leads to a specific set of bugs.

The symptom

A confirmation dialog that vanishes when you rotate the phone:

var showDialog by remember { mutableStateOf(false) }

Button(onClick = { showDialog = true }) { Text("Delete") }

if (showDialog) {
    AlertDialog(
        onDismissRequest = { showDialog = false },
        confirmButton = { TextButton(onClick = { delete(); showDialog = false }) { Text("Delete") } },
        dismissButton = { TextButton(onClick = { showDialog = false }) { Text("Cancel") } },
        title = { Text("Delete message?") },
    )
}

Open it, rotate, and it's gone — the user's confirmation prompt disappeared mid-decision. remember doesn't survive a configuration change, and Day 14 already named the fix.

The second symptom is subtler: this dialog knows that something should be deleted but not what, so the moment there's a list, a second variable appears to hold the target and the two can disagree.

Why the obvious fix fails

The obvious fix for rotation is rememberSaveable:

var showDialog by rememberSaveable { mutableStateOf(false) }

Correct, and it doesn't touch the second problem. With a list you end up here:

var showDialog by rememberSaveable { mutableStateOf(false) }
var pendingId by rememberSaveable { mutableStateOf<String?>(null) }

Two variables encoding one fact, so showDialog = true with pendingId = null is representable and will eventually happen — usually as a dialog with a blank message name, or a delete that removes nothing.

The actual mechanism

A dialog is a composable that exists when the state says it does. So model the state properly, and the impossible combination stops being expressible:

sealed interface DialogState {
    data object Hidden : DialogState
    data class ConfirmDelete(val messageId: String, val subject: String) : DialogState
}

var dialog by rememberSaveable(stateSaver = DialogStateSaver) {
    mutableStateOf<DialogState>(DialogState.Hidden)
}

when (val d = dialog) {
    DialogState.Hidden -> Unit
    is DialogState.ConfirmDelete -> AlertDialog(
        onDismissRequest = { dialog = DialogState.Hidden },
        title = { Text("Delete \"${d.subject}\"?") },
        text = { Text("This cannot be undone.") },
        confirmButton = {
            TextButton(onClick = { viewModel.delete(d.messageId); dialog = DialogState.Hidden }) {
                Text("Delete")
            }
        },
        dismissButton = {
            TextButton(onClick = { dialog = DialogState.Hidden }) { Text("Cancel") }
        },
    )
}

That's Day 12's sealed-hierarchy argument applied to modality: showing a dialog always carries its data, because the type won't let it not.

Note the button hierarchy follows Day 36 — both are TextButton, and the destructive one isn't filled, so the default visual weight doesn't push toward the irreversible action.

Dialog versus AlertDialog

AlertDialog is the Material component with title/text/buttons slots. Dialog is the primitive that gives you a window and nothing else:

Dialog(onDismissRequest = { … }) {
    Surface(shape = MaterialTheme.shapes.large) { CustomContent() }
}

Use Dialog when the content isn't a message-and-buttons — a date picker, an image viewer, a small form. DialogProperties controls the behaviours people usually want:

Dialog(
    onDismissRequest = { … },
    properties = DialogProperties(
        dismissOnBackPress = false,
        dismissOnClickOutside = false,      // for a step the user must complete
        usePlatformDefaultWidth = false,    // for a wide dialog on a tablet
    ),
)

usePlatformDefaultWidth = false is the one to know: without it, a dialog is capped at the platform's dialog width and your carefully sized tablet layout is ignored.

Bottom sheets, and the state object

ModalBottomSheet follows the same conditional-existence model, plus a state object because it animates:

val sheetState = rememberModalBottomSheetState()
val scope = rememberCoroutineScope()

if (showSheet) {
    ModalBottomSheet(
        onDismissRequest = { showSheet = false },
        sheetState = sheetState,
    ) {
        ShareOptions(onPick = { option ->
            scope.launch { sheetState.hide() }.invokeOnCompletion {
                if (!sheetState.isVisible) showSheet = false
            }
        })
    }
}

That hide()-then-clear dance is the part worth copying. Setting showSheet = false directly removes the composable immediately and the sheet disappears without animating out. Calling hide() first plays the animation, and the invokeOnCompletion removes it once the animation is done.

skipPartiallyExpanded = true on the state is worth setting for short sheets, which otherwise open to a half-height detent that looks like a bug when the content is smaller than that.

The non-modal BottomSheetScaffold is a different component for a different purpose — persistent sheets that coexist with the content, like a map's place card.

Where the state should live

The dialog state above is in the composition, which is right for most cases: nobody minds if a confirmation prompt closes on process death.

Move it to the ViewModel when the dialog reflects something the ViewModel owns — a network error to acknowledge, or a flow with steps. The test is Day 12's: would losing it lose work, or lose a decision the user already made?

One anti-pattern worth naming: routing dialogs through a SharedFlow of one-off events to "show" them. That reintroduces the imperative model on top of the declarative one, and brings back the delivery problems — an event emitted while the screen is backgrounded is either lost or shown late.

How to prove it

The rotation bug has a direct test:

@Test fun dialogSurvivesRecreation() {
    composeRule.onNodeWithText("Delete").performClick()
    composeRule.onNodeWithText("Delete message?").assertExists()
    activityRule.scenario.recreate()
    composeRule.onNodeWithText("Delete message?").assertExists()
}

And the impossible-state one is a compile-time check rather than a test — if DialogState.ConfirmDelete requires an id, there is no way to show the dialog without one. That's the argument for the sealed type in a sentence.

On device, the check that catches the sheet animation issue: open a bottom sheet, pick an option, and watch whether it slides away or blinks out.

Predictive back

Android 14+ shows a preview of where back will take you, and modal surfaces participate automatically — but only if you let the framework own the dismissal.

// Works with predictive back
ModalBottomSheet(onDismissRequest = { showSheet = false }) { … }

// Fights it
BackHandler { showSheet = false }        // intercepts before the animation runs

A manual BackHandler on a screen that also has a dialog swallows the gesture, so the system can't render its preview and the sheet snaps shut. The rule: let onDismissRequest handle back for modal surfaces, and reserve BackHandler for navigation decisions the framework can't infer — like Day 31's pane navigator.

What this generalizes to

The shift is from imperative commands to declarative state. showDialog() is an instruction with a moment attached; dialog is ConfirmDelete is a fact that's true until it isn't. Facts survive recreation and re-render correctly; moments have to be replayed, and replaying them is where the bugs live.

That's the same trade as Day 5's "you can't reach into the UI". Every place Compose removed an imperative call, it replaced a moment with a fact — and the reward each time is that rotation, process death and recomposition stop needing special handling — and the reward each time is that rotation, process death and recomposition stop being special cases.

Tomorrow, Day 40: navigation components — the bar, rail and drawer, and picking between them with the size classes from Day 29.


Day 39 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Dialog.