DEV Community

Cover image for Three Moments in a Composable’s Life: Composition, Recomposition, and Measure-Time Subcomposition
Android Expert
Android Expert

Posted on Originally published at Medium

Three Moments in a Composable’s Life: Composition, Recomposition, and Measure-Time Subcomposition

Most Compose bugs I get pulled into are not syntax bugs. They are timing bugs. Someone reads state in the wrong place, builds a layout that needs a size before it exists, or assumes a remember block lives longer than it does. Each of those traces back to one question: when does my composable body run, and what triggered it?

This post separates three ideas that tend to get blurred together:

  • Composition: the first execution of a composable when it enters the tree.
  • Recomposition: a later, targeted re-execution after state it read has changed.
  • Deferred composition: composing children during the layout phase, because their content depends on constraints or sizes that only exist at that point.

We will go through each one, look at the code that makes the distinction visible, and finish with six failure modes I have seen in production apps.

Where these ideas sit in a frame

Compose renders a frame in three phases: composition, layout, drawing. Composition and recomposition live in the first phase. Deferred composition is the odd one out, because it reaches forward and runs inside the second.

frame-phases

Keep that picture in mind. Every section below is a closer look at one of its arrows.

Composition: the first run

When a composable enters the tree for the first time, the runtime executes its body, executes every composable it calls, and records what happened in a structure called the slot table. The slot table stores groups, remembered values, and the information needed to diff against on the next run.

@Composable
fun ProfileHeader(user: User) {
    // Runs during initial composition, and again only if something invalidates it.
    val formatter = remember { DateFormat.getDateInstance(DateFormat.MEDIUM) }

    Column {
        Avatar(url = user.avatarUrl)
        Text(user.displayName)
        Text(formatter.format(user.joinedAt))
    }
}
Enter fullscreen mode Exit fullscreen mode

Two clarifications are worth making, because the simplified version (“composition happens once”) causes real mistakes.

Once per entry, not once per screen. If a composable leaves the tree and comes back, it is a fresh composition and remember starts empty. An if (expanded) { Details() } branch, a navigation destination being popped, or a lazy item scrolling far enough away can all trigger this. State that must survive belongs in rememberSaveable, a ViewModel, or a hoisted holder.

The body is not a place for side effects. The runtime is allowed to run, abandon, and re-run a composition. Writing to a database or firing analytics from the body is a bug waiting for a retry to expose it. Use LaunchedEffect, DisposableEffect, or SideEffect, which have defined timing.

// Fragile: may run more than once, or run for a composition that is thrown away.
@Composable
fun ScreenViewed(name: String) {
    analytics.track("screen_viewed", name)
}

// Correct: tied to the composition actually being applied and to the key.
@Composable
fun ScreenViewed(name: String) {
    LaunchedEffect(name) { analytics.track("screen_viewed", name) }
}
Enter fullscreen mode Exit fullscreen mode

Recomposition: the targeted re-run

Recomposition is not “redraw the screen.” It is the runtime re-executing only the scopes that were invalidated, then reusing everything else.

A scope becomes invalid when a snapshot State it read during composition changes. The runtime finds the nearest enclosing restartable scope and queues it. When that scope re-runs, each child call is compared to its previous inputs. If the inputs are equal and the child is skippable, the call is skipped without executing the body.

recomposition-skipping

A small instrument makes this visible. Drop it into any suspect composable while debugging:

import android.util.Log

@Composable
fun RecompositionLog(tag: String) {
    val count = remember { intArrayOf(0) }
    SideEffect {
        count[0]++
        Log.d("Recomposition", "$tag ran ${count[0]} time(s)")
    }
}
Enter fullscreen mode Exit fullscreen mode

SideEffect fires only for compositions that are actually applied, so recompositions the runtime abandons are not counted. That is what you want when measuring user-visible work, but it means the number can be lower than how many times the body executed.

What decides whether a child is skipped

Skipping depends on the inputs being stable and equal. A parameter type the compiler cannot prove stable (a plain List<T>, a class from a module without the Compose compiler, a class with var properties) used to make the whole call non-skippable. Recent Compose compiler versions enable strong skipping by default (it became the default with Kotlin 2.0.20, via the Compose compiler Gradle plugin; on older setups it is an opt-in flag). With it on, every restartable composable becomes skippable: stable parameters are compared with equals(), and unstable ones are compared by instance identity (===). Lambdas that capture unstable values are also memoized. That softens the problem but does not remove it: a new List instance on every parent pass is a different instance, so it still defeats skipping.

// Allocates a new list each time the parent runs. Equal contents, different instance.
@Composable
fun Inbox(messages: List<Message>) {
    MessageList(items = messages.filter { !it.archived })
}

// Cache the derived value against the thing it depends on.
@Composable
fun Inbox(messages: List<Message>) {
    val visible = remember(messages) { messages.filter { !it.archived } }
    MessageList(items = visible)
}
Enter fullscreen mode Exit fullscreen mode

Where you read state matters more than how often it changes

This is the single highest-leverage habit in Compose performance. The scope that reads the value is the scope that recomposes.

// Reads the offset in the body: every scroll pixel recomposes ScrollTitle.
@Composable
fun ScrollTitle(listState: LazyListState) {
    val offset = listState.firstVisibleItemScrollOffset
    Text(
        text = "Library",
        modifier = Modifier.padding(top = (offset / 4).dp)
    )
}

// Defer the read into the layout phase. Composition is untouched while scrolling.
@Composable
fun ScrollTitle(listState: LazyListState) {
    Text(
        text = "Library",
        modifier = Modifier.offset {
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 4)
        }
    )
}
Enter fullscreen mode Exit fullscreen mode

When you only need a threshold, not the raw value, derivedStateOf collapses thousands of changes into a handful of invalidations:

val showJumpToTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 3 }
}
Enter fullscreen mode Exit fullscreen mode

Note the vocabulary here. Moving a read into a lambda modifier is a deferred state read. It is related to, but not the same as, deferred composition, which is the subject of the next section.

Deferred composition: composing during layout

Normally composition finishes before layout starts. That ordering breaks down when the content of a child depends on information layout produces: the available width, the viewport height, or the measured size of a sibling. Composing first and measuring later cannot express that.

Compose solves this with subcomposition: a layout can call subcompose while it is measuring, which creates (or updates) an independent child composition on demand.

Two things you already use do this:

  • LazyColumn and friends only compose the items that fit the viewport. They can only know which items fit once layout knows the viewport size, so item composition is driven from the measure pass, with some prefetching to hide the latency.
  • BoxWithConstraints and Scaffold use subcomposition so their slot content can see constraints or sibling sizes.

SubcomposeLayout is the explicit, general form.

subcompose-flow

A header-and-body layout that survives real constraints

The textbook version of this layout works in a demo and fails in an app. This one handles loose constraints, unbounded parents, and windows too small for the header:

private enum class Slot { Header, Body }

@Composable
fun HeaderBodyLayout(
    modifier: Modifier = Modifier,
    header: @Composable () -> Unit,
    body: @Composable () -> Unit,
) {
    SubcomposeLayout(modifier) { incoming ->
        // Children may be smaller than the slot we were offered.
        val loose = incoming.copy(minWidth = 0, minHeight = 0)

        val headerPlaceables = subcompose(Slot.Header, header).map { it.measure(loose) }
        val headerHeight = headerPlaceables.maxOfOrNull { it.height } ?: 0

        // Remaining space for the body: clamp at zero, and respect an unbounded parent.
        val bodyMaxHeight = if (incoming.hasBoundedHeight) {
            (incoming.maxHeight - headerHeight).coerceAtLeast(0)
        } else {
            Constraints.Infinity
        }
        val bodyPlaceables = subcompose(Slot.Body, body)
            .map { it.measure(loose.copy(maxHeight = bodyMaxHeight)) }
        val bodyHeight = bodyPlaceables.maxOfOrNull { it.height } ?: 0

        val width = (maxOf(
            headerPlaceables.maxOfOrNull { it.width } ?: 0,
            bodyPlaceables.maxOfOrNull { it.width } ?: 0,
        )).coerceIn(incoming.minWidth, incoming.maxWidth)
        val height = (headerHeight + bodyHeight)
            .coerceIn(incoming.minHeight, incoming.maxHeight)

        layout(width, height) {
            headerPlaceables.forEach { it.placeRelative(0, 0) }
            bodyPlaceables.forEach { it.placeRelative(0, headerHeight) }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

One caveat about the unbounded branch. When the parent offers infinite height, the body is measured with Constraints.Infinity. That keeps the layout itself from crashing, but it does not make the body's content safe: a LazyColumn placed in body under an unbounded parent (for example inside verticalScroll) will still throw, for the same reason as failure mode 2 below. The layout is defensive about its own math, not about what you put in the slots. Place it under a bounded parent, or keep scrollable content out of body in that situation.

Usage reads like any other layout, and the body can be a lazy list that sizes itself to whatever the header left behind (here fillMaxSize gives it a bounded height):

HeaderBodyLayout(
    modifier = Modifier.fillMaxSize(),
    header = { SearchBar(query, onQueryChange) },
    body = { ResultsList(results) },
)
Enter fullscreen mode Exit fullscreen mode

Why not just measure the header with onSizeChanged?

You can, and many apps do:

var headerHeight by remember { mutableIntStateOf(0) }
Column {
    Header(Modifier.onSizeChanged { headerHeight = it.height })
    Body(maxHeight = with(LocalDensity.current) { headerHeight.toDp() })
}
Enter fullscreen mode Exit fullscreen mode

The cost: the size is only known after the first layout, so the body renders once with a wrong value, then a state write triggers recomposition, then it renders again. You get an extra pass and often a visible one-frame jump. Subcomposition removes the round trip because the dependency is resolved inside a single measure pass.

The three at a glance

concepts-at-a-glance

Six failure modes worth memorizing

failure-modes

Four of these deserve a closer look.

1. Negative or unbounded remaining space

Constraints validates its arguments. A maxHeight below zero throws, and so does a maxHeight smaller than minHeight. The naive constraints.maxHeight - header.height hits this on split-screen windows, landscape phones, or when the IME is open. The fix is in the layout above: reset minHeight, clamp with coerceAtLeast(0), and branch on hasBoundedHeight.

2. Lazy content under a scroller

// Crashes at measure time: the lazy list is offered infinite height.
Column(Modifier.verticalScroll(rememberScrollState())) {
    Header()
    LazyColumn { items(rows) { RowItem(it) } }
}
Enter fullscreen mode Exit fullscreen mode

Compose throws an IllegalStateException about a vertically scrollable component measured with infinite maximum height. Either give the inner list a bounded height, or make the whole screen one LazyColumn with a header item.

3. Unkeyed lazy items

Without keys, item identity is positional. Insert at the top and every remembered value shifts one row. Provide a stable key, and a content type if rows differ in structure so the runtime can reuse compatible compositions:

LazyColumn {
    items(
        items = messages,
        key = { it.id },
        contentType = { it.kind },
    ) { message ->
        MessageRow(message)
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Reaching for SubcomposeLayout by default

Subcomposition is more expensive than a plain Layout, and SubcomposeLayout does not support intrinsic measurements by default. Putting BoxWithConstraints in every row of a long list is a common way to turn smooth scrolling into dropped frames. Use it when the composition genuinely depends on constraints, not when you only need to measure something. If a modifier or a plain Layout can do the job, prefer that.

A debugging checklist

When something feels slow or wrong, ask in this order:

  1. Which phase is this happening in? The Layout Inspector’s recomposition counts tell you whether composition is the problem or layout is.
  2. Which scope reads the changing value? If a fast-changing State is read high in the tree, move the read down or into a lambda.
  3. Are the inputs equal? Log with the RecompositionLog helper, then look for fresh List, lambda, or object instances created on every pass.
  4. Is subcomposition justified? If a child’s content does not depend on constraints, a plain layout is cheaper.
  5. What happens at the edges? Zero height, unbounded height, a window half the size you tested, and a reordered list.

🔚 Takeaways

  • Composition is when the tree is built. Treat it as a pure function that may run more than once.
  • Recomposition is a scoped re-run keyed on what was read. Where you read state decides what recomposes.
  • Deferred composition exists for exactly one reason: some children cannot be composed until layout knows something. Use it for that, and write its constraint math defensively.

If you internalize the “which phase, which scope, which trigger” triple, most Compose performance and correctness bugs become short investigations rather than long ones.

📱 Level Up Your Android System Design Game

If you’re prepping for a senior Android role and system design rounds are the part that makes you sweat, I highly recommend “Mastering Android System Design” — a 37-chapter playbook that treats Android like the constrained runtime it actually is, not a small server. It covers Binder IPC, ART’s GC, process death, and background execution limits, then walks through the CRDDS interview framework and 13 full worked case studies (chat, ride-hailing, video streaming, payments, and more) carried end-to-end through API design, offline sync, and the Android-specific edge cases that trip candidates up mid-interview.


Originally published on Medium.

Top comments (0)