Composable Functions and Recomposition Guide

30 March 2026

Jetpack Compose Android

When I first started writing Compose, I treated composable functions like regular Kotlin functions that happened to return UI. Call them with the right parameters, get the right output. Simple. But the more I dug into recomposition bugs — UI not updating, composables re-executing when they shouldn’t, unexplained jank during scrolling — the more I realized that composable functions are fundamentally different from regular functions. The @Composable annotation isn’t just a marker. It rewrites how the compiler treats your code, and understanding that rewrite is the difference between fighting Compose and working with it.

I spent a lot of time reading the Compose compiler source and studying how engineers like Zack Klipp and Leland Richardson describe the internals. What follows is how I think about composable functions, recomposition, and the stability system — the mental model that finally made the “magic” feel predictable.

What @Composable Actually Does

When you mark a function with @Composable, the Compose compiler plugin transforms it at compile time. The most important transformation: it adds a hidden Composer parameter to every composable function. Your @Composable fun Greeting(name: String) becomes something like fun Greeting(name: String, $composer: Composer, $changed: Int) in the generated bytecode. You never see this parameter, but it’s always there.

The Composer is the engine behind everything. It manages the slot table — a flat, gap-buffer-backed array where Compose stores the state of your composition. Every remember call, every mutableStateOf, every composable invocation occupies entries in this slot table. When your composable executes during initial composition, the Composer writes new entries. When it re-executes during recomposition, the Composer walks the existing entries, compares outputs, and decides what changed.

// What you write
@Composable
fun UserProfile(user: User) {
    val formattedName = remember(user.id) { formatDisplayName(user) }
    Text(formattedName)
    Text(user.email)
}

// What the compiler generates (simplified)
fun UserProfile(user: User, $composer: Composer, $changed: Int) {
    $composer.startRestartGroup(...)
    val formattedName = $composer.cache(user.id) { formatDisplayName(user) }
    Text(formattedName, $composer, ...)
    Text(user.email, $composer, ...)
    $composer.endRestartGroup()?.updateScope { ... }
}

The compiler also tags each composable with two important properties: restartable and skippable. A restartable composable can serve as a recomposition scope — Compose can re-enter the function at that point when state changes, without re-executing everything above it. A skippable composable can be entirely skipped if its parameters haven’t changed. Most composables are both, but the compiler decides based on the function’s return type, annotations, and parameter stability. A composable that returns a non-Unit value is never skippable. A composable with unstable parameters is not skippable either (unless strong skipping mode is enabled).

Here’s the insight that changed how I think about this: a composable function is not “a function that returns UI.” It’s a node in a composition tree. The Composer maintains a tree of these nodes, tracks which state each node reads, and surgically re-executes only the nodes whose inputs changed. The slot table is the memory of this tree. remember is a slot table read. Recomposition is a tree diff.

Recomposition Rules

Recomposition is the process of Compose calling your composable functions again when state changes. But it doesn’t call all of them. The runtime follows specific rules that determine which composables re-execute and which get skipped.

Rule 1: State reads drive recomposition. Compose uses the snapshot system to track every State object read during composition. When a MutableState value changes, the snapshot system notifies the recomposer, which identifies every composable scope that read that state and schedules them for re-execution. If your composable doesn’t read any changing state, it never recomposes (after initial composition). This is why moving state reads into lambdas — like Modifier.offset { IntOffset(0, scrollY.value) } — is such a powerful optimization. The read moves from composition phase to layout phase, so the composable itself never recomposes.

Rule 2: Only composables that read changed state recompose. If a parent composable changes state, its children don’t automatically recompose. Compose checks each child’s parameters. If the parameters are stable and equal to the previous composition’s values, the child is skipped. This is “smart recomposition” — the runtime does the minimum work necessary.

@Composable
fun Dashboard(viewModel: DashboardViewModel) {
    val notifications by viewModel.notificationCount.collectAsStateWithLifecycle()
    val userName by viewModel.userName.collectAsStateWithLifecycle()

    // When notifications changes, only NotificationBadge recomposes
    // UserHeader is skipped because userName didn't change
    UserHeader(name = userName)
    NotificationBadge(count = notifications)
}

Rule 3: Recomposition can happen in any order. Compose doesn’t guarantee that parent composables run before children, or that siblings run left-to-right. The runtime can reorder execution for efficiency. This means you should never write composable code that depends on execution order or stores side effects in shared mutable variables.

Rule 4: Recomposition is optimistic and may be cancelled. Compose starts recomposing based on what it thinks changed. If a new state change arrives before recomposition finishes, Compose may cancel the in-progress recomposition and restart with the new state. This is why composable functions must be side-effect-free — if they write to a shared variable, a cancelled recomposition could leave that variable in an inconsistent state. Side effects belong in LaunchedEffect, DisposableEffect, or callbacks like onClick, not in the composable body.

Rule 5: Composable functions can run very frequently. During animations, composables connected to animated state may run every frame — 60 to 120 times per second. If your composable does expensive work (file I/O, complex calculations, network calls), that work happens on every frame. This is why remember exists: to cache computation results across recompositions.

Stability and Skipping

Compose’s ability to skip composables during recomposition depends entirely on the stability of their parameters. A type is “stable” in Compose’s terms if it satisfies a specific contract: its equals implementation is consistent (same inputs always produce the same result), and if a public property changes, Compose is notified through the snapshot system.

Types that Compose considers stable by default include all primitives (Int, Float, Boolean, etc.), String, function types (lambdas), and MutableState<T>. Data classes are stable if every property uses a stable type. That last part is where most developers hit problems.

// STABLE — all properties are primitive/String
data class UserProfile(
    val id: Long,
    val name: String,
    val email: String,
)

// UNSTABLE — List is a Kotlin interface, could be mutable underneath
data class SearchResults(
    val query: String,
    val items: List<Product>,  // List makes the whole class unstable
)

When a composable receives unstable parameters, Compose cannot skip it during recomposition — it must always re-execute the function because it can’t prove the parameters haven’t changed. This is the number one cause of excessive recompositions in real apps. Your composable might look simple and lightweight, but if it takes a List parameter, Compose will re-run it every single time its parent recomposes, even if the list contents are identical.

You can check your composables’ stability using the Compose compiler reports. Add -P plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=/path/to/reports to your Kotlin compiler arguments. The report tells you exactly which composables are restartable, skippable, and which parameters are stable or unstable. I run these reports on every module in our project and treat any skippable regression as a bug.

The @Stable and @Immutable annotations let you override the compiler’s stability inference. @Immutable is a promise that the object’s properties will never change after construction — stronger than @Stable, which allows mutation as long as it’s observable through Compose’s snapshot system. Use @Immutable for truly immutable data holders. Use @Stable for objects with observable mutable properties, like a state holder backed by MutableState fields.

@Immutable
data class CartState(
    val items: List<CartItem>,
    val total: Double,
)

@Stable
class FormState {
    var email by mutableStateOf("")
    var password by mutableStateOf("")
    var isValid by mutableStateOf(false)
}

One thing to be careful about: these annotations are promises you make to the compiler, not enforcement mechanisms. If you mark a class @Immutable and then mutate it, Compose will skip recomposition and your UI will be stale. No error, no warning. Just a silent bug.

Smart Recomposition

Compose doesn’t recompose individual lines of code. It recomposes at scope boundaries — the nearest restartable composable function. Understanding where these boundaries are is the key to controlling how far state changes propagate through your UI tree.

Every restartable composable function creates a scope. When state changes inside that scope, the entire function body re-executes. This means that a large composable that reads fast-changing state will re-execute everything inside it, even the parts that don’t depend on that state.

// BAD: the entire screen recomposes when timer changes
@Composable
fun GameScreen(viewModel: GameViewModel) {
    val timer by viewModel.timer.collectAsStateWithLifecycle()
    val score by viewModel.score.collectAsStateWithLifecycle()
    val playerName by viewModel.playerName.collectAsStateWithLifecycle()

    Column {
        Text("Player: $playerName")
        Text("Score: $score")
        Text("Time: $timer")  // changes every second
        GameBoard(viewModel)   // expensive composable re-executes every second
    }
}

// GOOD: extract the timer into its own scope
@Composable
fun GameScreen(viewModel: GameViewModel) {
    val score by viewModel.score.collectAsStateWithLifecycle()
    val playerName by viewModel.playerName.collectAsStateWithLifecycle()

    Column {
        Text("Player: $playerName")
        Text("Score: $score")
        TimerDisplay(viewModel)  // isolated scope, only this recomposes
        GameBoard(viewModel)
    }
}

@Composable
private fun TimerDisplay(viewModel: GameViewModel) {
    val timer by viewModel.timer.collectAsStateWithLifecycle()
    Text("Time: $timer")
}

By extracting TimerDisplay into its own composable, the timer’s state read is now inside a separate scope. When the timer changes, only TimerDisplay recomposes. GameBoard, which is expensive, stays untouched.

Lambda functions also create implicit scopes. The content lambda of Button, Column, Box, and other container composables is a separate restartable scope. This means state reads inside a Button { ... } content block won’t cause the parent composable to recompose — they recompose only the lambda. But CompositionLocal reads break this pattern. A CompositionLocal read anywhere in the tree invalidates the scope where the read occurs. If you read a frequently-changing CompositionLocal high in the tree, you’ll see cascading recompositions.

I’ve found that the single most effective performance technique in Compose is pushing state reads as deep and as late as possible. Read state in the composable that actually displays it, not in a parent that passes it down. This naturally creates tight recomposition boundaries.

Common Recomposition Traps

After working with Compose on production apps, I keep running into the same set of mistakes. Here are the ones that cost the most time to debug.

Creating Objects Inside Composable Scope

Every time a composable recomposes, its body re-executes. If you create a new object in the body — a new list, a new data class, a new lambda — that object is a new instance. Even if its contents are identical to the previous recomposition, Compose sees a different reference and considers it “changed.” Any child composable receiving that object cannot be skipped.

// **Wrong** — creates a new list on every recomposition
@Composable
fun SettingsScreen(prefs: UserPreferences) {
    val options = listOf(
        Option("Dark Mode", prefs.darkMode),
        Option("Notifications", prefs.notifications),
    )
    OptionsList(options = options)  // never skipped, new list every time
}

// **Correct** — remember the list, recompute only when inputs change
@Composable
fun SettingsScreen(prefs: UserPreferences) {
    val options = remember(prefs.darkMode, prefs.notifications) {
        listOf(
            Option("Dark Mode", prefs.darkMode),
            Option("Notifications", prefs.notifications),
        )
    }
    OptionsList(options = options)
}

Forgetting remember for Computed Values

If you compute a value from state without remember, the computation runs on every recomposition. For cheap operations, this is fine. For expensive ones — filtering a large list, formatting dates, regex matching — it adds up fast.

// **Wrong** — filters the entire list on every recomposition
@Composable
fun ContactList(contacts: List<Contact>, query: String) {
    val filtered = contacts.filter { it.name.contains(query, ignoreCase = true) }
    LazyColumn {
        items(filtered, key = { it.id }) { ContactRow(it) }
    }
}

// **Correct** — remember the filtered result, recompute only when inputs change
@Composable
fun ContactList(contacts: List<Contact>, query: String) {
    val filtered = remember(contacts, query) {
        contacts.filter { it.name.contains(query, ignoreCase = true) }
    }
    LazyColumn {
        items(filtered, key = { it.id }) { ContactRow(it) }
    }
}

Passing Unstable Types as Parameters

This is the one I see most in production code. You pass a List, Map, or a data class with mutable fields to a child composable, and that child can never be skipped. The fix depends on the situation: wrap in an @Immutable holder, use kotlinx.collections.immutable, or restructure to pass stable primitives instead of the whole object.

// **Wrong** — List parameter makes TagRow always recompose
@Composable
fun ProductCard(product: Product, tags: List<String>) {
    Column {
        Text(product.name)
        TagRow(tags = tags)  // recomposes every time parent recomposes
    }
}

// **Correct** — wrap in an immutable holder
@Immutable
data class TagList(val values: List<String>)

@Composable
fun ProductCard(product: Product, tags: TagList) {
    Column {
        Text(product.name)
        TagRow(tags = tags)  // now skippable
    }
}

With strong skipping mode (default since Compose compiler 2.0), some of these issues are mitigated because the runtime falls back to reference equality (===) for unstable types. If you pass the same List instance, the composable is skipped even though List is unstable. But if you create a new list instance with the same contents on every recomposition, you’re back to the same problem. Strong skipping mode changed the failure mode from “always recomposes” to “recomposes when reference changes” — better, but still something you need to be aware of.

Quiz

Question 1: You have a composable that takes a data class UserInfo(val name: String, val friends: List<String>) as a parameter. With strong skipping mode disabled, will Compose ever skip this composable during recomposition?

Wrong: Yes, because UserInfo is a data class with a proper equals implementation.

Correct: No. UserInfo contains List<String>, which is unstable. The entire class is inferred as unstable, so Compose will always recompose this composable when its parent recomposes, regardless of the equals result. Stability is checked at the type level, not the value level.


Question 2: You read a MutableState value inside a Modifier.graphicsLayer { } lambda. Does changing that state trigger recomposition of the composable?

Wrong: Yes, any state change triggers recomposition of composables that use it.

Correct: No. graphicsLayer reads state during the draw phase, not the composition phase. The snapshot system only tracks reads for the currently active snapshot scope. Since the read happens in a draw-phase lambda, it triggers a redraw but skips composition and layout entirely.

Coding Challenge

Build a LiveSearchScreen composable that demonstrates proper recomposition scoping. The screen has a search input at the top and a filtered list of items below. Requirements:

  1. The search input state should be hoisted in a remember-ed TextFieldState or MutableState<String>
  2. The filtered list should be computed using remember(query, items) to avoid recomputation on unrelated recompositions
  3. Each list item should be a separate composable that receives stable parameters (only primitives or @Immutable types) so it can be skipped
  4. The list item’s onClick lambda should not capture any unstable references — use a stable identifier like item.id instead of the full item object

Run the Compose compiler metrics on your solution and verify that your list item composable is marked as both restartable and skippable.

Thanks for reading!