Jetpack Compose Mechanisms, Part 1: How the Compiler Turns a Function into a Composable

skydovesJaewoong Eum (skydoves)||21 min read

Jetpack Compose Mechanisms, Part 1: How the Compiler Turns a Function into a Composable

Jetpack Compose reached its stable 1.0 release in July 2021, and in May 2026 Google declared Android UI development Compose First. You probably write @Composable functions every day. Add that one annotation to an ordinary Kotlin function and it becomes UI that remembers values between runs, runs again on its own when data changes, and sometimes decides not to run at all. The body you wrote does none of this by itself, so what does?

In this article, you'll dive deep into what the Compose compiler does to that function, starting from a rule you have already hit and working inward: the calling context, the hidden $composer parameter, restart groups and idempotence, the compiler plugin model, and the generated code that decides whether a composable runs or skips.

This is the first article of a three part series based on my DroidKaigi 2026 & Droidcon Berlin 2026 talk, Jetpack Compose Mechanisms. You can follow along with the slides, and the diagrams in this series come from them. Part 2 follows the runtime and the UI layer, and Part 3 applies the whole picture to performance work.

The three layers: Compiler, runtime, and UI

Compose is usually introduced as a UI toolkit, but only two of its three parts are libraries. The third is a compiler plugin, and each part hands its output to the next.

Composable functions pass through the Compose compiler, the Compose runtime, and Compose UI before they become pixels

  1. The Compose compiler transforms your composable functions into a form the runtime can work with. It runs at build time.
  2. The Compose runtime executes the transformed functions and keeps a representation of the composition in memory: what was called, in what order, and which values were remembered along the way.
  3. Compose UI turns that representation into a tree of layout nodes, measures and places them, and draws the result into pixels.

You can see the split in a module's build file. The compiler is a Kotlin compiler plugin that is released with Kotlin and versioned with it, while the runtime and the UI layer are ordinary library dependencies. The runtime is spelled out below for clarity, though it usually arrives transitively through ui:

plugins {
    id("org.jetbrains.kotlin.plugin.compose")
}

dependencies {
    implementation(platform("androidx.compose:compose-bom:<version>"))
    implementation("androidx.compose.runtime:runtime")
    implementation("androidx.compose.ui:ui")
}

This article stays with the first stage and follows one small function through the compiler.

The fundamental problem: A function that returns nothing

Start with the smallest composable you can write:

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

Notice what the signature leaves out: a return type. Greeting returns Unit, like every composable that emits UI. It does not return a view or any other UI object. Instead, while it runs, it emits information into the composition, and the UI is built from what was emitted.

A composable takes data and, while it runs, emits into the composition

That leaves questions an ordinary Kotlin function has no answer for. If nothing is returned, where does the emitted information go, and how does Text know where it sits relative to Greeting? When the data behind name changes, how does Compose run again only the code that depends on it, instead of the whole screen? And when name has not changed, how does it know it can skip the work? A normal signature has no room to carry any of this, and a normal body has no way to report it. The compiler adds both: a parameter that carries the missing context, and calls in the body that report to the runtime.

The calling context: Composables can only be called from composables

The first visible effect of @Composable is a rule you have probably run into already. A composable function can only be called from another composable function:

@Composable
fun Place(name: String) {
    Person(name) // compiles
}

fun place(name: String) {
    Person(name) // compile error
}

@Composable
fun Person(name: String) {
    Text(name)
}

The second call fails with "@Composable invocations can only happen from the context of a @Composable function", and place itself gets a second error telling you that functions which invoke composables must be marked @Composable. This rule is known as the calling context, and if it feels familiar, that is because Kotlin coroutines have the same one.

The suspend analogy: Continuation passing style

A suspend function can only be called from another suspend function or from inside a coroutine:

suspend fun fetchProfile(name: String): Profile {
    return profileDao.findByName(name)
}

coroutineScope.launch {
    fetchProfile(name = "skydoves")
}

The reason is visible in what the Kotlin compiler produces. It rewrites every suspend function in continuation passing style: it appends a hidden $completion parameter of type Continuation, and it changes the return type to Any?, so the function can return either its result or a marker that says it has suspended:

fun fetchProfile(name: String, $completion: Continuation<Profile>): Any?

The continuation carries everything needed to resume the work later, and a function can only pass one along if it received one from its own caller. If you want to see the full transformation, The Machinery Behind the Magic: How Kotlin Turns suspend into State Machines walks through it. Composable functions follow the same pattern with a different hidden parameter.

The hidden parameter: $composer

Compile Greeting and its signature changes. The listings in this article are the compiler's output printed back as Kotlin, so they can contain things source code would not allow. The new signature looks like this:

fun Greeting(name: String, $composer: Composer?, $changed: Int)

Two parameters appeared that you never wrote. $composer is the runtime context, the object through which generated code talks to the Compose runtime. $changed carries what the caller already knows about each argument, and it comes back at the end of this article. You never pass either one yourself.

The compiler adds $composer to every composable, and every composable call inside a body passes the same instance along. Take a function that calls three other composables:

@Composable
fun NamePlate(name: String, lastname: String) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(text = name)
        Text(text = lastname)
    }
}

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor