DropdownMenu and Autocomplete Patterns in Jetpack Compose

Quick answer: Use
DropdownMenufor a short set of actions anchored to a button, useExposedDropdownMenuBoxfor a selected value or autocomplete field anchored to text input, and keep the selected value, query, options, and loading state in a screen state holder. Always close the menu after selection and when the user dismisses it.
Menus and autocomplete fields may both show a floating list, but they solve different problems. A menu offers temporary actions; an exposed dropdown represents a form value; autocomplete helps users find an item among candidates. Choosing the right pattern prevents an action menu from becoming an awkward form field—or a 1,000-item menu from becoming an unusable search UI.
Choose the pattern by the user’s task
| User task | Use | Anchor |
|---|---|---|
| Run one of a few contextual actions | DropdownMenu | Icon button or overflow button |
| Pick one visible form value | Read-only exposed dropdown | OutlinedTextField |
| Type and choose a suggestion | Editable exposed dropdown | TextField |
| Search a large or remote catalog | Search field plus results screen/list | Dedicated search UI |
The Material 3 DropdownMenu API describes menus as temporary surfaces anchored to a parent layout. The ExposedDropdownMenuBox API describes an exposed menu as a text-field-anchored control that can also accept input for autocomplete.
Build a small action menu
DropdownMenu does not occupy layout space; it appears in a separate popup over its parent. Put it beside the control that opens it and model the expanded state explicitly.
@Composable
fun NoteActionsMenu(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onAction: (NoteAction) -> Unit,
) {
Box {
IconButton(onClick = { onExpandedChange(true) }) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.cd_more_note_actions),
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { onExpandedChange(false) },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.edit)) },
onClick = {
onAction(NoteAction.Edit)
onExpandedChange(false)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.archive)) },
onClick = {
onAction(NoteAction.Archive)
onExpandedChange(false)
},
)
}
}
}The caller owns expanded; a screen typically gets it from lifecycle-aware state collection and sends onExpandedChange back as an action. Dismissal is not optional: onDismissRequest is called when users tap outside the popup, and every item should close the menu after it sends its action.
DropdownMenuItem gives Material-consistent layout and interaction behavior. Do not make the menu root a LazyColumn: the Material 3 reference states that menu content already lives in a scrollable Column, so a root lazy list inside it is unsupported. For a large catalog, use a search-results list instead.
Use an exposed dropdown for a form choice
An exposed dropdown shows the current selection in a text field. Make the field read-only when users must choose from a controlled set, such as country, account type, or shipping speed.
@Composable
fun CountrySelector(
selectedCountry: CountryUi?,
countries: List<CountryUi>,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onCountrySelected: (CountryUi) -> Unit,
) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = onExpandedChange,
) {
OutlinedTextField(
value = selectedCountry?.label.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.country)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier.menuAnchor(
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { onExpandedChange(false) },
) {
countries.forEach { country ->
DropdownMenuItem(
text = { Text(country.label) },
onClick = {
onCountrySelected(country)
onExpandedChange(false)
},
)
}
}
}
}The anchor is essential. The menuAnchor reference explains that it handles focus, expand/collapse interaction, and menu semantics. PrimaryNotEditable communicates that the main text field opens a controlled choice; it is not a free-text editor.
The CountrySelector only renders values and forwards events. Filtering, validation, and persistence belong in the state holder, just as in state-hoisting examples. For a small fixed list, passing countries directly is appropriate; for a remote list, the ViewModel should expose results and loading/error state.
Build autocomplete with an editable anchor
Autocomplete uses an editable text field and suggestions derived from the query outside the composable. The UI receives the query and matching options, then asks the state holder to update either one.
@Composable
fun CityAutocomplete(
query: String,
suggestions: List<CityUi>,
expanded: Boolean,
onQueryChange: (String) -> Unit,
onExpandedChange: (Boolean) -> Unit,
onCitySelected: (CityUi) -> Unit,
) {
ExposedDropdownMenuBox(
expanded = expanded && suggestions.isNotEmpty(),
onExpandedChange = onExpandedChange,
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
label = { Text(stringResource(R.string.city)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable),
)
ExposedDropdownMenu(
expanded = expanded && suggestions.isNotEmpty(),
onDismissRequest = { onExpandedChange(false) },
) {
suggestions.forEach { city ->
DropdownMenuItem(
text = { Text(city.label) },
onClick = {
onCitySelected(city)
onExpandedChange(false)
},
)
}
}
}
}PrimaryEditable identifies the field as the editable primary anchor. Recent Material 3 API references list both PrimaryEditable and PrimaryNotEditable; check the Material 3 version in your project when upgrading because menu-anchor overloads have evolved.
Avoid filtering a large repository or launching a network request directly from onValueChange. Dispatch the query event to the ViewModel, debounce or cancel work there, and expose a stable UI state. The TextField state guide explains how a text field’s state model affects this boundary.
Handle empty, loading, and no-match states deliberately
An empty suggestion list should not look like a broken menu. Decide what the screen shows for each case:
- Empty query: hide suggestions or show a small, intentional recent-items list.
- Loading: keep the input usable and show progress near it, not an endlessly empty popup.
- No matches: show a concise message below the field or a menu item that cannot be selected.
- Failure: keep the typed query and show a retry path.
For server-backed suggestions, use a minimum query length only when it fits the product. Do not silently drop valid one-character searches in a language or catalog where they are meaningful. The progress-indicator guide is useful for representing real loading work without inventing progress.
Accessibility and keyboard behavior
menuAnchor applies menu semantics and focus behavior, but an accessible field still needs a real label. Use label, visible supporting text for errors, and contentDescription for an icon-only action-menu button. Do not duplicate click targets by making both an entire parent and its child text field independently open the same exposed menu.
Test these paths:
- Tap and keyboard opening/closing of the menu.
- Outside-tap dismissal and back navigation.
- Focus movement from field to suggestions and back.
- TalkBack announcement of the field label, selected value, and each suggestion.
- Large text, narrow widths, and an open keyboard.
ExposedDropdownMenu automatically constrains its popup so it does not overlap the anchored text field or software keyboard. The scope API documents this sizing behavior. Still test it on short devices: a long suggestion list is a product-design problem, not merely a layout constraint.
Common mistakes
Using a menu as navigation
Menus are temporary action lists. Use the app’s navigation structure for destinations users need to discover or revisit; do not hide core screens behind an overflow menu.
Leaving the popup open after a selection
A chosen autocomplete result should update the state and dismiss the popup as one interaction. An open menu after selection makes it unclear whether the value was accepted.
Putting hundreds of rows in DropdownMenu
Menus are compact. A large catalog needs filtering, pagination, or a dedicated search screen. Keep menu options short enough to scan and select.
Treating autocomplete text as a selected entity
The query is not necessarily a valid selection. Keep a selected item ID separately from the text the user is currently typing, and validate before submitting the form. See form validation in Compose for the surrounding submission pattern.
FAQ
Is DropdownMenu suitable for a long list?
No. It scrolls, but it is still a compact temporary menu and its root content is not designed for a nested lazy list. Use a search or picker screen for large data.
Should an exposed dropdown field be read-only?
Yes when a user must choose exactly one option from the list. Keep it editable only when free typing and suggestions are both valid product behavior.
Where should autocomplete filtering run?
In the ViewModel, domain layer, or data layer—not inside the composable. The composable should render the query and suggestions, then forward events.
Summary
Use DropdownMenu for small contextual actions and ExposedDropdownMenuBox for text-field-anchored selection. Declare the anchor type, own selection and query state outside the UI, dismiss reliably, and replace large menus with a search-oriented experience.