Lesson 1

What `@Composable` changes

Build the mental model for a composable function type and its implicit composer context.

4 min readUpdated Jul 11, 2026

Status: The idea that @Composable changes a function’s type and supplies a composition context is Durable . The names and generated calls below are Version-specific at the pinned revisions.

Outcome

You should be able to explain why this function cannot be called from an ordinary function:

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

The short answer is not “the annotation turns on magic.” It changes the function’s type. A call now participates in a compiler/runtime protocol.

Start with the observable difference

Greeting returns Unit. It does not return a View, a LayoutNode, or a tree object. Yet it can describe content and later run again at the same logical position.

The runtime needs context for that position. The pinned AndroidX Composable.kt describes a useful model: a composable context is passed implicitly, and it can store information from previous executions at the same logical point.

That is also why a composable lambda has a distinct type:

val content: @Composable () -> Unit = { Text("Hello") }

The annotation is part of the type. A value of () -> Unit is not interchangeable with @Composable () -> Unit.

The five-part mental model

Diagram
flowchart LR
    Source[Kotlin source] --> Type[Composable function type]
    Type --> Composer[Implicit Composer context]
    Composer --> Groups[Groups and runtime memory]
    Groups --> Changes[Calculated changes]
    Changes --> Nodes[Target tree]

The context is not a global singleton. It is a compiler-facing Composer value threaded through generated code; transformed functions also carry change information. Lesson 2 inspects those parameters.

This explains two common observations:

  • A wrapper composable can participate in composition without creating a node.
  • The same body can execute again while the target tree keeps existing nodes.

The function describes a transformation in a composition. It is not a constructor for a returned widget.

A source walk: annotation to protocol

The current Kotlin compiler source makes this transformation explicit in ComposerParamTransformer.kt. Its lowering adds a synthetic composer parameter to composable declarations and adds synthetic change parameters. It also rewrites calls to composable functions so the caller supplies the same protocol context.

A captured compiler golden output shows the resulting shape. This is a shortened excerpt from the pinned FunctionReferenceTransformTests/reference.txt:

fun Ref(content: Function3<Int, Composer, Int, Unit>, %composer: Composer?, %changed: Int) {
  %composer = %composer.startRestartGroup(<>)
  if (%composer.shouldExecute(%changed and 0b0001 != 0, %changed and 0b0001)) {
    Ref(<block>{
      sourceInformationMarkerStart(%composer, <>, "CC(remember):Test.kt#9igjgp")
      val tmp0_group = %composer.cache(false) {
        ::Fn
      }
      sourceInformationMarkerEnd(%composer)
      tmp0_group
    }, %composer, 0b0110)
  } else {
    %composer.skipToGroupEnd()
  }
  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
    Ref(content, %composer, updateChangedFlags(%changed or 0b0001))
  }
}

<> is the golden renderer’s placeholder for a compiler-generated integer. The example is captured output, not pseudocode. The excerpt intentionally omits source-information and memoization details; those are not needed for this first mental model.

Try the type boundary

Use a compiler-enabled scratch module and test both directions:

fun ordinary(content: () -> Unit) = content()

@Composable
fun caller() {
    ordinary { /* ordinary lambda */ }
    val c: @Composable () -> Unit = { Text("ok") }
    // ordinary(c) should be rejected: the function types differ.
}

Control: remove @Composable from c; the ordinary call should then type-check, but Text can no longer be called inside that lambda.

Expected observation: the annotation changes both where a lambda may be called and which calls the Compose compiler rewrites.

Interpretation limit: this experiment proves a type/lowering boundary. It does not prove a particular runtime scheduling policy or a node insertion.

Misconception check

@Composable is only metadata that tools read later.”

No. The annotation is binary-retained metadata, but it also changes the function type and triggers compiler lowering. The runtime-facing parameters are implementation details, while the type distinction is the durable model.

Check yourself

Why can Greeting return Unit and still update a UI tree? Name the hidden participant that gives the call access to composition memory, then name the separate boundary that eventually mutates target nodes.

Evidence notes

ClaimDirect evidenceStatus
@Composable changes a function or lambda type and models an implicit contextComposable.ktDurable
The compiler adds composer and change parameters to composable declarations and callsComposerParamTransformer.ktVersion-specific
A composable body uses the Composer protocol and may skip or restartComposer.kt and pinned golden outputVersion-specific

Freshness

Refresh this lesson when the Composable KDoc changes, compiler lowering moves the implicit context, or the compiler/runtime protocol changes shape. Re-pin both repositories before treating a generated signature as current.

Finished this lesson?

Your progress stays only in this browser.