Blog Infos
Author
Published
Topics
, , ,
Published

Understanding Jetpack Compose Beyond UI

When most of us think of Jetpack Compose, we automatically think of UI.

I remember hearing Jake Wharton mention that Jetpack Compose UI is just one implementation built on top of the Jetpack Compose runtime, and that it’s unfortunate the naming didn’t make that distinction clearer — because Compose itself is much broader than a UI toolkit.

I’ve always been drawn to understanding how seemingly magical things actually work, and Jetpack Compose was one of those systems that felt both magical and intimidating.

In this post, I want to break down — as I understand it, and in the simplest terms possible — what Jetpack Compose really is. I’ll also make a clear distinction between Jetpack Compose and Jetpack Compose UI, and finally show how we can leverage the same runtime for non-UI use cases such as dependency injection (DI).

What is Jetpack Compose (tl;dr)?

Jetpack Compose is a compiler plugin and a runtime that let you build and manage tree-like structures and their state.

The runtime provides the engine for composable functions, and the compiler plugin rewrites your code so the runtime can understand and manage it.

What do we mean by “runtime”?

Think of the runtime as the engine that powers all composable logic.

It handles:

  • Tracking State changes
  • Remembering values across recompositions (remember())
  • Coordinating updates and re-entries of composable scopes

Essentially, it provides the “machinery” that makes @Composable functions possible.

What do we mean by “compiler plugin”?

compiler plugin is a tool that extends how the Kotlin compiler processes your code. It hooks into the compilation pipeline and can analyzetransform, or generate additional code before the final program output is produced.

Depending on the target, that output might be JVM bytecodeJavaScript, or native binaries — but the key idea is that plugins can inject custom behavior into the compilation process itself.

The Compose compiler plugin does exactly that: it transforms your @Composable functions so they can interact with the Compose runtime.

For example, when we write something like:

@Composable
fun Screen() { }

 

You’re implicitly telling the compiler:

“Please rewrite this function into something the Compose runtime can manage.”

Without going too deep into compiler internals, the Kotlin compiler has several stages — two important ones are FIR (Front-end Intermediate Representation) and IR (Intermediate Representation).

During the IR stage, the Compose compiler plugin transforms your composable into something conceptually like this:

@Composable
fun Screen($composer: Composer, $changed: Int) { ... }

 

Note: This is similar to what you’d see if you decompiled the .class file — not the raw IR output itself.

This transformation allows the runtime to call your composable functions, track state, and schedule recompositions efficiently.

So where does Jetpack Compose UI fit in?

Jetpack Compose UI is just one implementation that uses the Compose runtime to generate UI hierarchies.

While the runtime has certainly been optimized for the UI use case, it’s actually agnostic — it doesn’t care whether you’re building UI components, dependency graphs, or data pipelines.

From the runtime’s point of view, it’s still orchestrating the creation and maintenance of a tree-like structure with stable state and efficient updates.

How does the runtime work (tl;dr)?

The runtime has six main building blocks:

SlotTable

In-memory data structure that keeps track of groups and nodes — a flattened representation of the current composition.

Nodes

Abstract entities representing entries within the SlotTable. Some of these are NodeGroups, which correspond to tangible nodes managed by an Applier.

Applier

The mechanism that translates NodeGroups into a materialized tree. It is the actual use case that leverages the compose runtime (UI, DI, JSON, etc.).

Composition

The scope that defines how the SlotTable and Applier work together for a given composable hierarchy.

Recomposer

The coordinator that observes state changes and schedules recompositions at the right time.

MonotonicFrameClock

The clock that drives when recompositions occur — effectively the “game loop” of Compose. On Android, it’s tied to the Choreographer which schedules updates once per VSYNC frame (typically every 16.6 ms at 60 Hz, or faster on 90 Hz/120 Hz displays), while in frameworks like Molecule, it runs as fast as possible to emit continuous state updates.

Understanding the SlotTable

If the runtime is the engine, the SlotTable is its memory.

It’s an in-memory data structure that keeps track of everything that has been composed: which composables ran, what state they produced, and what nodes were emitted.

Think of it as a flattened, serialized view of your composition tree — a sequence of groups and slots representing the structure and remembered state of your tree.

🧠 The SlotTable in action

Take this @Composable for example:

@Composable
fun MyBox() {
    Box {
        val greeting = remember { "Hello" }
        Text(greeting)
    }
}

 

During composition, the runtime builds a sequence of groups and slots inside the SlotTable.

Index  | Group      | Entry Type                 | Notes
0      | MyBox      | RestartableGroup           | Root scope of the composable
1      | NodeGroup  | Box node                   | 
2      | Slot       | remembered value "Hello"   |
3      | NodeGroup  | Text node                  |
4      | GroupEnd   | End of group               |

At runtime it would look something like this (simplified):

composer.startRestartGroup("MyBox")
composer.startNode() // → Create a LayoutNode for Box
composer.createNode(<BoxFactory>)
composer.useNode()

composer.startGroup("remember")
composer.startReusableGroup()
composer.startReplaceableGroup()
composer.startReaderGroup()
// Remember slot operations
slot = composer.cache { "Hello" }
composer.endReaderGroup()
composer.endReplaceableGroup()
composer.endReusableGroup()
composer.endGroup()

composer.startNode() // → Create a LayoutNode for Text
composer.createNode(<TextFactory>)
composer.useNode()
composer.endNode() // End Text node
composer.endNode() // End Box node
composer.endRestartGroup()
Groups 101

Groups are the bookends of the composition process. When the Compose compiler plugin rewrites a composable, it wraps it with startGroup() / endGroup() calls to mark its boundaries.

Each group can hold:

  • Child groups
  • Remembered values (slots)
  • References to nodes

Groups are what allow Compose to re-enter only the parts of a function that need updating.

The different types of groups

RestartableGroup

  • Every composable function
  • Can be recomposed independently

ReusableGroup

  • Compiler/runtime optimization
  • Skipped or reused efficiently

MovableGroup

  • movableContentOf {}
  • Can move across parents without recomposition (think LazyColumn).

NodeGroup

  • ComposeNode()
  • Connects composition to the Applier’s tree

Key Group

  • key()
  • Gives a group a stable identity across recompositions

Remember Slot

  • remember {}
  • Stores persistent state within the current group
What happens during recomposition?

Recomposition is where the SlotTable shows its power. Let’s say that in our previous example, the greeting value changes:

  1. The MutableState inside remember invalidates the MyBox group.
  2. The Recomposer schedules that group for recomposition.
  3. The Composer re-enters the existing SlotTable, reusing its structure.
  4. When it reaches the remember, it finds the existing slot and reuses the stored value instead of recreating it.
  5. The Text NodeGroup is reused unless its inputs changed.
  6. All other groups are skipped.
Why remember values survive recompositions?

remember works because the SlotTable is persistent — it isn’t rebuilt on every recomposition.

Each time the Composer walks the table, it matches the current call structure to the existing entries. If the structure is the same, it simply reuses those slots.

On the first composition:

  • The slot is empty → the lambda runs → the value is stored.

On later runs:

  • The slot is found → the value is reused → the lambda isn’t called.

That’s why state declared with remember survives even though the composable function itself re-executes.

The Applier connection

All NodeGroups that appear in the SlotTable are passed to the Applier, which decides what “applying” means:

  • In Jetpack Compose UI, the UiApplier inserts and updates LayoutNodes.
  • In our DI example, the DiApplier registers dependency factories into the DI graph.
  • In a JSON builder, a JsonApplier could output JSON elements.

Without an Applier, nodes are still recorded in the SlotTable, but nothing is materialized — they stay as logical entries.

Visual mental model
Compose Compiler Plugin
↓
Transforms @Composable → runtime-compatible functions
↓
Runtime (Engine)
├── SlotTable (state + structure)
├── Composer (writes/reads slots)
├── Applier (materializes nodes)
├── Recomposer (schedules updates)
↓
Consumer Implementation
├── UI hierarchy
├── DI graph
└── Anything else
Building a DI framework with Compose

Once you see that Compose is really a stateful tree engine, not just a UI system, you realize it can power anything that fits that model.

Let’s build a DI system using the same principles.

Step 1 — Define a DiApplier
sealed interface DINode {
class Root(val graph: DiGraph) : DINode
class Singleton<T : Any>(
val key: String,
val provider: DiGraph.() -> T
) : DINode
}

class DiApplier(root: DINode.Root) : AbstractApplier<DINode>(root) {
private val graph: DiGraph get() = (root as DINode.Root).graphoverride

fun insertTopDown(index: Int, instance: DINode) {
when (instance) {
is DINode.Root -> Unit
is DINode.Singleton<*> -> {
graph.registerSingleton(instance.key, instance.provider)
}
}
}

override fun insertBottomUp(index: Int, instance: DINode) = Unit
override fun remove(index: Int, count: Int) = Unit
override fun move(from: Int, to: Int, count: Int) = Unit
}

 

Step 2 — Define a DiGraph
class DiGraph {
internal val factories = mutableMapOf<String, () -> Any>()
internal val singletons = mutableMapOf<String, Any>()
fun <T : Any> registerSingleton(
key: String,
provider: DiGraph.() -> T
) {
factories[key] = {
singletons.getOrPut(key) { provider() }
}
}
inline fun <reified T : Any> get(): T {
val key = requireNotNull(T::class.qualifiedName)
val factory = factories[key]
?: error("No service of type $key found")
return factory() as T
}
}
Step 3 — Build a module DSL
@DslMarker
annotation class DIModuleMarker

interface Definition {
    val key: String
}

class SingleDef(
    override val key: String,
    val provider: DiGraph.() -> Any
) : Definition

@DIModuleMarker
class Module internal constructor() {
    internal val definitions = mutableListOf<Definition>()

inline fun <reified T : Any> single(
        noinline provider: DiGraph.() -> T
    ) {
        definitions += SingleDef(
            requireNotNull(T::class.qualifiedName),
            provider
        )
    }
}

fun module(block: Module.() -> Unit): Module =
    Module().apply(block)
Step 4 — Create the composable registration function
@Composable
fun register(vararg modules: Module) {
modules.forEach { module ->
module.definitions.forEach { def ->
when (def) {
is SingleDef -> {
ComposeNode<DINode.Singleton<*>, DiApplier>(
factory = {
DINode.Singleton(def.key, def.provider)
},
update = {}
)
}
}
}
}
}
Step 5 — Install the DI system
@Composable
fun install(vararg modules: Module, content: @Composable () -> Unit) {
val parentContext = rememberCompositionContext()
val graph = remember { DiGraph() }
val applier = remember { DiApplier(DINode.Root(graph)) }
val diComposition = remember {
Composition(applier, parentContext)
}

diComposition.setContent {
register(*modules)
}

CompositionLocalProvider(LocalDI provides graph) {
content()
}
}
Step 6 — Inject dependencies
@Composable
inline fun <reified T : Any> inject(): T {
    val diGraph = LocalDI.current
    val provider = requireNotNull(diGraph.factories[T::class.qualifiedName])
    return provider() as T
}
Example usage
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        val module = module {
            single {
                DataSource()
            }
            single {
                Repository(get())
            }
        }
        setContent {
            install(module) {
                ComposedDITheme {
                    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                        Greeting(modifier = Modifier.padding(innerPadding))
                    }
                }
            }
        }
    }
}


@Composable
fun Greeting(modifier: Modifier = Modifier, repository: Repository = inject()) {
    Text(
        text = repository.data(),
        modifier = modifier
    )
}
When this runs, the runtime does exactly what it would for UI:
  • The Compose compiler transforms your composables.
  • The SlotTable records the groups and remembered state.
  • The Recomposer coordinates recompositions.
  • The DiApplier builds and registers dependencies in the graph.

Noteregister() is where ComposeNodes are generated. This is what ultimately results in the Applier being invoked allowing us to materialize our DI graph.

The compiler plugin challenge

Initially, I tried to build a custom compiler plugin to automate dependency injection. I wanted to go full circle and understand how the Compose compiler plugin transformed code for runtime consumption.

The idea was to generate wrapper functions automatically for any @Composable function with @ComposeInject parameters:

Note: Please note that I wanted to do so just to learn, it is much more practical to just invoke inject() manually. The @ComposeInject annotation does not add much value.

@Composable
fun Greeting(
modifier: Modifier = Modifier,
@ComposeInject repository: Repository
) {
Text(text = repository.data(), modifier = modifier)
}

 

The plugin would generate:

@Composable
fun Greeting(modifier: Modifier = Modifier) {
Greeting(modifier, inject<Repository>())
}

 

or:

@Composable
fun Greeting(
    modifier: Modifier = Modifier,
    Repository: Repository = inject()
) {
    Text(text = repository.data(), modifier = modifier)
}
The problem with K2 IR generation

I built both the FIR extension (to generate the wrapper function declaration) and the IR extension (to fill in the function body).

The FIR phase successfully created the wrapper function, and the IR phase could generate the body that calls inject<Repository>() and delegates to the original function.

But there was a critical limitation:

I couldn’t make the generated wrapper function @Composable in the IR phase. This might be due to my own lack of knowledge of about the Kotlin K2 compiler and compilers in general.

The Compose compiler plugin had already applied its changes, and it only transforms functions that are already marked with @Composable. Since I generated the wrapper in IR (after FIR), I couldn’t add the annotation in a way that the Compose compiler would recognize.

Without the @Composable annotation being processed by the Compose compiler, our wrapper couldn’t receive the synthetic $composer and $changed parameters, which meant:

  • The inject() function couldn’t access the Compose runtime
  • The wrapper couldn’t properly delegate to composable functions
  • The entire chain broke down

I tried several approaches:

  • Adding the annotation in FIR (but couldn’t generate the body there)
  • Adding it in IR (but Compose compiler had already run)
  • Manually adding $composer parameters (incompatible with Compose’s expectations)

None worked.

The KSP solution

Since the K2 compiler plugin approach hit a wall, I switched to KSP (Kotlin Symbol Processing).

KSP runs before compilation, which means:

  • Generated code goes through the full Compose compiler pipeline
  • The @Composable annotation is recognized
  • Everything just works
The KSP processor
class ComposeInjectProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger
) : SymbolProcessor {

    private val processedFunctions = mutableSetOf<String>()

    override fun process(resolver: Resolver): List<KSAnnotated> {
        logger.info("ComposeInject KSP: Starting processing round")

        val composeInjectAnnotation = "me.emmano.di.annotations.ComposeInject"

        val functionsToProcess = findFunctionsWithInjectParams(resolver, composeInjectAnnotation)

        logger.info("ComposeInject KSP: Found ${functionsToProcess.size} functions to process")

        functionsToProcess.forEach { function ->
            try {
                generateWrapper(function, composeInjectAnnotation)
            } catch (e: Exception) {
                logger.error("Error generating wrapper for ${function.simpleName.asString()}: ${e.message}", function)
            }
        }

        logger.info("ComposeInject KSP: Completed processing round")
        return emptyList()
    }

    private fun findFunctionsWithInjectParams(
        resolver: Resolver,
        annotationFqName: String
    ): List<KSFunctionDeclaration> {
        val functions = mutableListOf<KSFunctionDeclaration>()
        resolver.getAllFiles().forEach { file ->
            file.declarations
                .filterIsInstance<KSFunctionDeclaration>()
                .filter { function ->
                    function.parameters.any { param ->
                        param.hasAnnotation(annotationFqName)
                    }
                }
                .forEach { function ->
                    logger.info("Found function with @ComposeInject: ${function.qualifiedName?.asString()}")
                    functions.add(function)
                }
        }
        return functions
    }

    private fun generateWrapper(function: KSFunctionDeclaration, annotationFqName: String) {
        val packageName = function.packageName.asString()
        val functionName = function.simpleName.asString()
        val fullName = "${function.qualifiedName?.asString()}"

        if (processedFunctions.contains(fullName)) {
            logger.info("Skipping already processed function: $fullName")
            return
        }
        processedFunctions.add(fullName)

        logger.info("Generating wrapper for: $fullName")

        val wrapperFunction = buildWrapperFunction(function, functionName, annotationFqName)

        val fileSpec = FileSpec.builder(packageName, "${functionName}Generated")
            .addFileComment("Generated by ComposeInject KSP Processor", arrayOf<Any>())
            .addFileComment(
                "Wrapper function for $functionName with automatic dependency injection",
                arrayOf<Any>()
            )
            .addImport("me.emmano.di.compose", "inject")
            .addFunction(wrapperFunction)
            .build()

        fileSpec.writeTo(codeGenerator, Dependencies(false, function.containingFile!!))
        logger.info("Successfully generated wrapper: $fullName")
    }

    private fun buildWrapperFunction(
        function: KSFunctionDeclaration,
        functionName: String,
        annotationFqName: String
    ): FunSpec {
        val funSpecBuilder = FunSpec.builder(functionName)
            .addAnnotation(ClassName("androidx.compose.runtime", "Composable"))

        val nonInjectParams = function.parameters.filterNot { it.hasAnnotation(annotationFqName) }
        nonInjectParams.forEach { param ->
            val paramSpec = buildParameterSpec(param)
            funSpecBuilder.addParameter(paramSpec)
        }

        val bodyCode = buildFunctionBody(function, functionName, annotationFqName)
        funSpecBuilder.addCode(bodyCode)

        return funSpecBuilder.build()
    }

    private fun buildParameterSpec(param: KSValueParameter): ParameterSpec {
        val paramName = param.name?.asString() ?: error("Parameter has no name")
        val paramType = param.type.toTypeName()
        val paramSpecBuilder = ParameterSpec.builder(paramName, paramType)

        if (param.hasDefault) {
            if (paramType.toString().contains("Modifier")) {
                paramSpecBuilder.defaultValue("%T", ClassName("androidx.compose.ui", "Modifier"))
            }
        }

        return paramSpecBuilder.build()
    }

    private fun buildFunctionBody(
        function: KSFunctionDeclaration,
        functionName: String,
        annotationFqName: String
    ): CodeBlock {
        val codeBuilder = CodeBlock.builder()
        codeBuilder.add("%N(\n", functionName)
        codeBuilder.indent()

        function.parameters.forEachIndexed { index, param ->
            if (index > 0) codeBuilder.add(",\n")
            val paramName = param.name?.asString() ?: "param$index"
            val isInject = param.hasAnnotation(annotationFqName)
            if (isInject) {
                val paramType = param.type.resolve().toClassName()
                codeBuilder.add("%N = inject<%T>()", paramName, paramType)
            } else {
                codeBuilder.add("%N = %N", paramName, paramName)
            }
        }

        codeBuilder.add("\n")
        codeBuilder.unindent()
        codeBuilder.add(")\n")
        return codeBuilder.build()
    }

    private fun KSValueParameter.hasAnnotation(annotationFqName: String): Boolean {
        return annotations.any { annotation ->
            val annotationType = annotation.annotationType.resolve().declaration
            annotationType.qualifiedName?.asString() == annotationFqName ||
                    annotation.shortName.asString() == "ComposeInject"
        }
    }
}
Generated output
package me.emmano.di

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import me.emmano.di.compose.inject

@Composable
fun Greeting(modifier: Modifier = Modifier) {
    Greeting(modifier, inject<Repository>())
}

 

This works because:

  1. KSP generates source code before compilation.
  2. The Compose compiler sees the @Composable annotation.
  3. It adds the $composer and $changed parameters automatically.
  4. The inject() function can access the Compose runtime via CompositionLocal.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

The bigger picture

Jetpack Compose isn’t just a UI framework — it’s a general-purpose runtime for managing stateful trees.

Once we understand how the SlotTableComposerRecomposer, and Applier interact, the “magic” disappears. What’s left is a beautifully generic system for describing change over time.

Whether you use it to render pixels, wire dependencies, or generate structured data — the underlying principles remain the same.

Thanks for reading

If this helped you see Compose in a new light, share it — or better yet, try writing your own Applier. There’s no better way to understand magic than to rebuild it yourself. I have made all the code available here and thoroughly commented it so readers can follow along.

🙌 Acknowledgments

A big thank-you to Jorge Castillo for his book Compose Internals — one of the clearest explanations of the runtime’s inner workings available today.

Also, huge credit to the Molecule team at CashApp, whose open-source project beautifully demonstrates how the Compose runtime can power logic outside of UI.

📣 Disclaimer

It wouldn’t be ethical on my part not to mention that I relied heavily on AI to fully grasp many of these concepts and to generate parts of this material.

After reading Compose Internals I wanted to deepen my understanding of how Compose really works under the hood. I felt that writing a DI framework inspired by Koin would serve a dual purpose — helping me understand both Compose and Koin more deeply.

The initial implementation was simple enough to integrate easily with Compose. After several iterations, I asked AI to generate a DI framework that mirrored Koin’s API. Once I studied that output and understood the underlying design decisions, I started a brand-new project and implemented everything myself — both the DI framework and its integration point with Compose.

I think it’s important to include this disclaimer for the sake of transparency — to show how AI can be used as a learning accelerator rather than a replacement for genuine understanding.

This article was previously published on proandroiddev.com

Menu