Jetpack Compose: Create a button with a callback
Video: Jetpack Compose: Create a button with a callback by Taught by Celeste AI - AI Coding Coach
Jetpack Compose: Button with a Callback
Button(onClick = { ... }) { Text("Click me") }. Pass the click handler as a parameter for testability and reuse. The simplest interactive Composable.
A small Compose puzzle: build a button that runs a function when clicked. The pattern is foundational — every interactive widget in Compose follows the same shape.
The Copilot prompt
import androidx.compose.runtime.Composable
// A button that triggers a callback when clicked
@Composable
fun GreetingButton(onClickAction: () -> Unit) {
Copilot generates:
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@Composable
fun GreetingButton(onClickAction: () -> Unit) {
Button(onClick = { onClickAction() }) {
Text("Click me")
}
}
Walkthrough
1. The function signature. @Composable fun GreetingButton(onClickAction: () -> Unit). Composable functions are marked with @Composable — they can read state and create UI.
onClickAction: () -> Unit is a function parameter — a lambda taking no args, returning nothing. The caller provides whatever should happen on click.
2. Button(onClick = ..., content = ...). Material's button takes an onClick callback and a content slot. Hitting the button triggers the callback.
3. Text("Click me"). The button's label. Inside the button's content lambda, you can put any Composables — text, icons, rows, etc.
Calling the button
@Composable
fun MyScreen() {
GreetingButton(onClickAction = {
println("Button clicked!")
})
}
The caller controls what happens. The button itself is dumb — it just exposes a click event.
Why hoist the callback?
Compare two designs:
// Bad: hardcoded action
@Composable
fun GreetingButton() {
Button(onClick = {
println("Hello!")
}) {
Text("Click me")
}
}
// Good: action passed in
@Composable
fun GreetingButton(onClickAction: () -> Unit) {
Button(onClick = onClickAction) {
Text("Click me")
}
}
The hoisted version:
- Reusable. Same button can do different things in different contexts.
- Testable. UI tests can pass a mock onClickAction and verify it was called.
- Composable up the tree. State and logic stay in the parent / ViewModel.
This is state hoisting applied to events. The Composable doesn't know what to do; the parent does.
The Material 3 version
androidx.compose.material.Button is Material 2. Modern code uses Material 3:
import androidx.compose.material3.Button
import androidx.compose.material3.Text
@Composable
fun GreetingButton(onClickAction: () -> Unit) {
Button(onClick = onClickAction) {
Text("Click me")
}
}
The Material 3 button has rounded corners, subtle shadow, primary color from the theme. Same API.
Customizing appearance
import androidx.compose.material3.ButtonDefaults
import androidx.compose.ui.graphics.Color
@Composable
fun GreetingButton(onClickAction: () -> Unit) {
Button(
onClick = onClickAction,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Magenta,
contentColor = Color.White,
),
) {
Text("Click me", style = MaterialTheme.typography.titleMedium)
}
}
ButtonDefaults provides factories for the various color/elevation/shape configurations. For brand-specific styling, override containerColor and contentColor.
Other button types
OutlinedButton(onClick = ...) { Text("Outlined") } // border, no fill
TextButton(onClick = ...) { Text("Text") } // no border, no fill — flat
FilledTonalButton(onClick = ...) { Text("Tonal") } // mid-emphasis, tinted
IconButton(onClick = ...) { Icon(Icons.Default.Close, "Close") }
Material 3 has a hierarchy of button styles for different emphasis levels:
- Filled (
Button) — primary action, highest emphasis. - Filled tonal — secondary action.
- Outlined — alternative or cancel.
- Text — low-emphasis, often inline.
- Icon — for icon-only buttons.
Use the right level — too many Buttons on a screen looks busy.
Disabled state
Button(
onClick = onClickAction,
enabled = isLoading == false,
) { Text("Submit") }
enabled = false greys the button out and ignores clicks. Common pattern: disable during async work.
With a data parameter
@Composable
fun DeleteButton(itemId: Int, onDelete: (Int) -> Unit) {
Button(onClick = { onDelete(itemId) }) {
Text("Delete item $itemId")
}
}
The callback now takes the ID. Caller wires it up:
DeleteButton(itemId = 42, onDelete = { id -> viewModel.delete(id) })
Common mistakes
Forgetting @Composable. Function compiles, but using Composable APIs inside (like Button) errors. Every Composable function needs the annotation.
Doing work in the Composable body. A Composable runs on every recomposition. Side effects belong in LaunchedEffect, callbacks, or hoisted up. The body should be (mostly) pure UI declaration.
Hardcoding the callback. Couples the button to a specific action. Hoist via parameter for reuse.
Using Button.onClick = { someState++ }. Mutating local state directly is fine for tiny demos, but real apps hoist state to a ViewModel.
Wrong Material library. Mixing androidx.compose.material.* (Material 2) and androidx.compose.material3.* (Material 3) in the same screen produces inconsistent styling. Pick one.
What's next
Episode 16: A button that shows when it was clicked. Adds local state — the button toggles a flag and updates its own label.
Recap
@Composable fun MyButton(onClick: () -> Unit) — pass the click handler as a parameter for reuse and testability. Button(onClick = ...) from Material 3 (or 2). Use the hierarchy: Button (filled), FilledTonalButton, OutlinedButton, TextButton, IconButton. Customize via ButtonDefaults.buttonColors(...). State (clicked state, loading) hoists up via parameters; event handlers come down the same way.
Next episode: button with click state.