DEV Community

Cover image for That Weird `{ }` After Button? I Had No Idea What I Was Actually Writing{Lambda function}
Aalaa Fahiem
Aalaa Fahiem

Posted on

That Weird `{ }` After Button? I Had No Idea What I Was Actually Writing{Lambda function}

I remember the first time I looked at a Button in Jetpack Compose.

Button(onClick = { doSomething() }) {
    Text("Click me")
}
Enter fullscreen mode Exit fullscreen mode

I typed it. It worked. I moved on.

But I had a question I was too embarrassed to ask out loud: why are there two sets of curly braces here? What is that second block doing floating there after the closing parenthesis? Is that even valid Kotlin?

I copy-pasted it from the docs, trusted it, and kept going.

Then one day I tried to write my own composable that accepted a click listener. I wrote it like this:

@Composable
fun MyButton(label: String, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text(label)
    }
}
Enter fullscreen mode Exit fullscreen mode

And I called it like this:

MyButton(label = "Submit", onClick = { handleSubmit() })
Enter fullscreen mode Exit fullscreen mode

It worked. But then a teammate showed me I could also call it like this:

MyButton(label = "Submit") { handleSubmit() }
Enter fullscreen mode Exit fullscreen mode

I stared at that for a long time. Where did onClick = go? Why did the lambda just... float outside? Was this some kind of shortcut? Was I missing something?

That was the day I stopped ignoring lambdas and actually learned them.


First, What Even Is a Lambda?

Before Compose, before callbacks — let's go back to basics.

A lambda is simply a function that has no name. That's it. Instead of declaring a full function with fun, you write the logic inline, right where you need it.

Here's a normal function:

fun greet(name: String): String {
    return "Hello, $name"
}
Enter fullscreen mode Exit fullscreen mode

Here's the exact same thing as a lambda:

val greet = { name: String -> "Hello, $name" }
Enter fullscreen mode Exit fullscreen mode

Same logic. No name declaration. No return keyword — the last expression is returned automatically. You store it in a variable, or you pass it directly somewhere else.

That's a lambda.


Function Types: What You're Actually Passing Around

When you write () -> Unit, you're not writing magic. You're writing a type.

Just like String is a type, or Int is a type — () -> Unit is the type of a function that takes no parameters and returns nothing.

Let's break the pattern down:

(parameters) -> ReturnType
Enter fullscreen mode Exit fullscreen mode

So:

  • () -> Unit — takes nothing, returns nothing
  • (String) -> Unit — takes a String, returns nothing
  • (Int) -> Boolean — takes an Int, returns a Boolean
  • (String, Int) -> String — takes a String and an Int, returns a String

In Compose, you'll see () -> Unit constantly. Every onClick, every onDismiss, every onNavigate — they all have this type because they just do something when triggered, they don't return a value.


The Trailing Lambda — The Thing That Confused Me

Here's the rule that explains everything:

In Kotlin, if the last parameter of a function is a lambda, you can move it outside the parentheses.

That's it. That's the whole thing.

So this:

Button(onClick = { doSomething() }, content = { Text("Click me") })
Enter fullscreen mode Exit fullscreen mode

Can be written as this:

Button(onClick = { doSomething() }) {
    Text("Click me")
}
Enter fullscreen mode Exit fullscreen mode

Because content is the last parameter, Kotlin lets you pull it outside. And if there's only one lambda parameter and it's the last one, you can even drop the parentheses entirely:

Column(modifier = Modifier.fillMaxSize()) {
    Text("Hello")
}
Enter fullscreen mode Exit fullscreen mode

This is called a trailing lambda. It's not a different feature — it's just syntax sugar that Kotlin gives you for cleaner-looking code. And once you know it exists, you'll see it absolutely everywhere in Compose.


Lambdas as Event Callbacks: Starting Simple

Now let's see this in a real Compose context.

The simplest example is Button:

@Composable
fun MyScreen() {
    Button(onClick = { Log.d("TAG", "Button clicked!") }) {
        Text("Click me")
    }
}
Enter fullscreen mode Exit fullscreen mode

The onClick parameter has the type () -> Unit. You're passing a lambda — an anonymous function — directly inline. When the button is tapped, Compose calls your lambda.

Now what if you want to do something more meaningful when the button is clicked — like navigate or update state? You lift the logic out:

@Composable
fun MyScreen(onNavigate: () -> Unit) {
    Button(onClick = onNavigate) {
        Text("Go to next screen")
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the screen doesn't decide what happens on click. It just says "when clicked, call whatever you gave me." The caller decides the behavior. That's the entire power of passing functions as parameters.


Leveling Up: Custom Composables with Callbacks

This is where it gets interesting.

Imagine you're building a list of items. Each item has a name, and when the user taps one, you want to do something with it. Here's how that composable might look:

@Composable
fun PokemonItem(
    name: String,
    onItemClick: (String) -> Unit
) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .clickable { onItemClick(name) }
            .padding(8.dp)
    ) {
        Text(
            text = name,
            modifier = Modifier.padding(16.dp)
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice onItemClick: (String) -> Unit. This lambda takes a String and returns nothing. When the card is tapped, it calls the lambda with the Pokémon's name.

Now the screen that uses this composable decides what to do with that name:

@Composable
fun PokemonListScreen(onNavigateToDetail: (String) -> Unit) {
    val pokemonList = listOf("Pikachu", "Charizard", "Bulbasaur")

    LazyColumn {
        items(pokemonList) { pokemon ->
            PokemonItem(
                name = pokemon,
                onItemClick = { selectedName ->
                    onNavigateToDetail(selectedName)
                }
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Or using trailing lambda syntax, even shorter:

PokemonItem(name = pokemon) { selectedName ->
    onNavigateToDetail(selectedName)
}
Enter fullscreen mode Exit fullscreen mode

Same result. The PokemonItem composable knows nothing about navigation. It just knows it should call onItemClick when tapped. What happens next is someone else's responsibility.

That separation is not just clean — it makes your composables reusable anywhere.


One More Thing: Lambdas and Recomposition

Here's something most beginner articles skip — and it actually matters.

Every time Compose recomposes a screen, it re-executes the composable function. That means every lambda you write inline gets recreated on every recomposition.

For most cases, this is totally fine. But once you start passing lambdas deep into a component tree, or using them inside performance-sensitive composables, this can cause unnecessary recompositions in child components.

The solution Compose gives you is remember with lambdas:

val onClickAction = remember {
    { /* do something */ }
}
Enter fullscreen mode Exit fullscreen mode

Or for lambdas that depend on state:

val onItemClick: (String) -> Unit = remember(viewModel) {
    { name -> viewModel.onPokemonSelected(name) }
}
Enter fullscreen mode Exit fullscreen mode

This tells Compose: "don't recreate this lambda unless viewModel changes."

You don't need to do this everywhere from day one. But knowing it exists means when you eventually run into a recomposition issue, you'll know where to look.


The Mental Model That Tied It All Together

When I finally understood lambdas in Compose, I stopped seeing them as confusing syntax and started seeing them as the language of events.

Every tap, every input change, every navigation action — they're all just functions being passed around. Your composables don't handle what happens. They just say: "when this occurs, call whatever you gave me."

That's the real reason Compose is designed this way. It keeps each composable focused on one job: rendering. The rest is handled by whoever calls it.


Summary

Concept What It Means
Lambda A function with no name, written inline
() -> Unit A function type: takes nothing, returns nothing
(String) -> Unit A function type: takes a String, returns nothing
Trailing lambda When the last param is a lambda, move it outside ()
Callback pattern Passing lambdas down so composables can report events up
remember { lambda } Prevents unnecessary recomposition from lambda recreation

If any part of this clicked for you, or if you've been silently confused about the same thing — drop a comment. I'm still learning too, and honestly, the best thing about writing these articles is finding out I wasn't the only one who didn't get it at first.

Top comments (0)