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.

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 symptom
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.

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
suspendfunction? - 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 actual mechanism
@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
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:
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
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
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.