Engineering

What @Composable actually does to your function

@Composable is a compiler plugin that adds hidden parameters to your function. Once you know what it adds, the rules about calling context, skipping and recomposition stop being arbitrary.

MobileByteSensei 8 min read
Day 6 — What @Composable actually does to your function
androidcomposekotlininternals

Day 6 of 100. Yesterday's model — describe, don't mutate — leaves an obvious question unanswered. If a composable is just a function that returns Unit, how does anything it "describes" reach the screen?

The answer is that it isn't just a function. The annotation changes it.

The case this post is about

One composable and one call site that will not compile:

@Composable
fun Greeting(name: String) {
    Text("Hello $name")
}

fun onButtonClick() {
    Greeting("Ada")   // e: @Composable invocations can only happen from the
}                     //    context of a @Composable function

Nothing about Greeting is wrong, and the error reads like a permission rule — as though the annotation marks a function that is only allowed to be called from certain places.

It is not a permission. The annotation has rewritten Greeting to take two extra parameters, and onButtonClick has neither of them, so the call is impossible rather than forbidden. This post follows that one error, because every other rule about composables — why a coroutine lambda cannot call one, why parameter types affect performance — is the same fact in different clothes.

The symptom: the compiler rejects a composable that looks fine

You write your first composable, and the compiler rejects something that looks fine:

@Composable
fun Greeting(name: String) {
    Text("Hello $name")
}

fun onButtonClick() {
    Greeting("Ada")   // e: @Composable invocations can only happen from the
}                     //    context of a @Composable function

The obvious reading is "composables are special, you may only call them from special places" — a rule to memorise. Then you hit the second one:

@Composable
fun Timer() {
    val scope = rememberCoroutineScope()
    scope.launch {
        Text("tick")   // same error, inside a lambda in a composable
    }
}

Now the rule looks inconsistent. You are inside a @Composable function. Memorising "call composables from composables" doesn't explain why this fails.

The annotation adds a Composer parameter and a changed bitmask; the rules follow from the signature, not from convention

Why the obvious explanation fails

The usual explanation is that @Composable marks a function as "UI code", and the compiler enforces a convention about where UI code may appear. That's a description of the behaviour, not the mechanism, and it makes the second error look like a bug.

It also fails to explain any of these:

  • Why can't you call a composable from a suspend function?
  • Why does a composable's execution order matter for remember?
  • Why does adding a parameter sometimes make a composable stop skipping?

A convention can't explain those. A signature change can.

The mechanism: @Composable is a Kotlin compiler plugin

@Composable is a Kotlin compiler plugin. It rewrites the function's signature. Roughly — and this is deliberately simplified — this:

@Composable
fun Greeting(name: String) { … }

becomes something closer to this:

fun Greeting(name: String, $composer: Composer, $changed: Int) { … }

Two things get added.

$composer is the runtime object that actually builds and updates the UI tree. It holds the slot table — the data structure that remembers, per call site, what was emitted last time and what state was remembered there. Every composable needs it, and it's threaded implicitly through every call.

That single fact explains the first error completely. onButtonClick() is an ordinary function; it has no $composer to pass. The error isn't about permission — it's that the call is impossible, because a required argument doesn't exist at that point.

It also explains the second. scope.launch { … } takes an ordinary lambda, and that lambda's body executes later, on a coroutine, long after the composition that created it has finished. There is no valid $composer to capture, so the compiler refuses. Not an inconsistency — the same rule.

$changed is a bitmask describing which parameters changed since the last call. It is how skipping works. Before running the body, the generated code checks the mask: if nothing this composable depends on changed, and the composable is skippable, it returns immediately and reuses what's in the slot table.

That's why parameter types matter for performance. Compose can only set a "didn't change" bit if it can compare the old and new values cheaply and reliably — which means the type has to be stable. Pass a List<String> (an interface, potentially mutable, unknowable to the compiler) and it can't guarantee that, so it conservatively assumes changed, and your composable re-runs every time its parent does.

The fix: understand the rewrite rather than work around it

Nothing here needs fixing in your first composable — it needs understanding, because the consequences show up later. But two habits pay off immediately.

Keep composables' calling context honest. If you need to run code in response to an event, that code is not a composable and shouldn't pretend to be:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {      // ordinary lambda: no composables inside
        Text("Clicked $count times")     // composable lambda: Text is fine here
    }
}

onClick is a plain () -> Unit. The trailing content lambda is annotated @Composable in Button's own signature. The difference isn't stylistic; those are different types, and the compiler treats them differently.

Prefer stable parameter types. ImmutableList from kotlinx.collections.immutable, or a data class holding only stable properties, lets $changed do its job.

// Recomposes whenever the parent does — List is not stable
@Composable fun Names(names: List<String>) { … }

// Can skip when the value is unchanged
@Composable fun Names(names: ImmutableList<String>) { … }

Why call order matters, and what key is for

The slot table is positional. It identifies remembered values by where the call appears in the composition, not by variable name. That has a consequence people hit without understanding it:

@Composable
fun Items(users: List<User>) {
    users.forEach { user ->
        var expanded by remember { mutableStateOf(false) }
        UserRow(user, expanded) { expanded = !expanded }
    }
}

Expand the third row, then delete the first user. The list shifts up — and now the second row is expanded, because the remember slot at position 3 stayed where it was while the data moved. The state didn't follow its user; it stayed with its position.

key fixes it by giving the slot an identity that isn't positional:

The whole mechanism is small enough to build, and building it is what makes the positional-identity rule stop feeling arbitrary. A slot table keyed by call position, a remember that reads it, and a key that overrides the position:

class Composer {
    private val slots = mutableMapOf<List<Any>, Any?>()
    private var path = mutableListOf<Any>()     // the current call PATH, not a name
    private var counter = 0

    fun <T> remember(calculation: () -> T): T {
        val slot = path + counter++             // identity = where we are, period
        @Suppress("UNCHECKED_CAST")
        return slots.getOrPut(slot) { calculation() } as T
    }

    fun <R> key(id: Any, block: () -> R): R {
        val savedCounter = counter
        path.add(id); counter = 0               // a NAMED subtree: position no longer matters
        try { return block() } finally { path.removeLast(); counter = savedCounter }
    }

    fun beginComposition() { counter = 0 }      // positions are reassigned every pass
}

Run the two versions of the list against it — three rows, expand the third, delete the first:

val c = Composer()
fun compose(users: List<String>, keyed: Boolean) {
    c.beginComposition()
    users.forEach { u ->
        if (keyed) c.key(u) { c.remember { Expand(false) } } else c.remember { Expand(false) }
    }
}
unkeyed:  compose([ann, bob, cat])   slots {[0], [1], [2]}   expand slot [2]  -> cat open
          compose([bob, cat])        slots {[0], [1]}        bob reads [0], cat reads [1]
          -> cat CLOSED, and nothing is open where the user left it

keyed:    compose([ann, bob, cat])   slots {[ann,0], [bob,0], [cat,0]}  expand [cat,0]
          compose([bob, cat])        cat reads [cat,0]
          -> cat still open

Ten lines of map lookup, and the behaviour that looks like a Compose quirk falls out of it exactly. The state never followed the user because nothing in the unkeyed version ever knew the user's name — remember was handed a position and a lambda, and a position is all it had to go on.

users.forEach { user ->
    key(user.id) {
        var expanded by remember { mutableStateOf(false) }
        UserRow(user, expanded) { expanded = !expanded }
    }
}

Now the remembered value is keyed to user.id. Delete a user and their slot goes with them. This is the same reason LazyColumn's items() takes a key parameter, and why omitting it produces scroll-position and animation glitches that look random.

The general shape: anything the slot table holds is positional unless you say otherwise. Once you know the storage is positional, key stops being a mysterious extra and becomes the obvious tool.

How to prove it: read the Compose compiler reports

You can see the rewrite. Compose's compiler can emit its own reports:

./gradlew :app:assembleRelease \
  -Pandroidx.compose.compiler.plugins.kotlin.reportsDestination=build/compose-reports

That produces a *-composables.txt listing every composable with whether it is restartable and skippable, and a *-classes.txt explaining why each class was judged stable or unstable. If a composable you expected to skip is marked skippable: false, the classes report usually names the parameter responsible.

This is the tool that turns "I think this recomposes too much" into a specific answer, and it's worth wiring into a build variant before you need it rather than during an incident.

What this generalizes to: refuse 'it's a special construct' as an answer

The useful move here is refusing to accept "it's a special construct" as an explanation. @Composable looked like a convention with arbitrary rules; it's a signature change with mechanical consequences, and every rule falls out of the two parameters it adds.

That pattern repeats across Kotlin. suspend is the same shape of trick — it adds a Continuation parameter, which is exactly why you can't call a suspend function from an ordinary one. Same mechanism, same class of error message, same explanation.

When a language feature seems to impose rules for no reason, the reason is usually in the generated signature.

Tomorrow, Day 7, we start the UI-architecture pillar with recomposition: what actually triggers it, and why "the whole screen re-runs" is almost never what happens.


Day 6 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Compose tutorial and Compose compiler reports.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS