DEV Community

YADNYESH RANA
YADNYESH RANA

Posted on

Your @Stable Data Class Isn't the Recomposition Leak — the Lambda Next to It Is

The setup

You've got a @Stable data class, you're passing it into a composable, and Layout Inspector's recomposition counter is still climbing every time the parent redraws. The usual advice — "mark your classes @Stable or @Immutable" — doesn't fix it, because the leak isn't in the data class. It's in the lambda next to it.

@Stable
data class CartSummary(val itemCount: Int, val totalCents: Long)

@Composable
fun CartHeader(summary: CartSummary, onCheckout: () -> Unit) {
    Row {
        Text("${summary.itemCount} items — ${summary.totalCents.toFormattedPrice()}")
        Button(onClick = onCheckout) { Text("Checkout") }
    }
}
Enter fullscreen mode Exit fullscreen mode

CartHeader looks stable on paper: a @Stable data class and a function-typed parameter, which Compose treats as stable if the lambda itself is stable across recompositions. The bug lives in how the caller constructs that lambda.

The actual leak

@Composable
fun CartScreen(viewModel: CartViewModel) {
    val summary by viewModel.summary.collectAsStateWithLifecycle()
    var promoCode by remember { mutableStateOf("") }

    CartHeader(
        summary = summary,
        onCheckout = { viewModel.checkout(promoCode) } // new instance every recomposition
    )
}
Enter fullscreen mode Exit fullscreen mode

Every time CartScreen recomposes — and promoCode changing on every keystroke guarantees that — the trailing lambda passed to onCheckout is a new object. Compose's stability check for CartHeader compares the lambda instance, not its captured values' equality, so a structurally identical-looking lambda still reads as "changed," and CartHeader recomposes along with it, even though summary itself didn't change and the composable only cares about onCheckout being callable, not which promoCode snapshot it happens to close over.

This is the part Layout Inspector won't tell you directly: it'll show CartHeader recomposing on every keystroke in an unrelated text field, and the instinct is to go check CartSummary's stability, which is a dead end — it was never the problem.

The fix: stop capturing, start reading

The lambda doesn't need to capture promoCode by value at the point it's created — it needs to read promoCode's current value at the point it's called. rememberUpdatedState is built for exactly this split:

@Composable
fun CartScreen(viewModel: CartViewModel) {
    val summary by viewModel.summary.collectAsStateWithLifecycle()
    var promoCode by remember { mutableStateOf("") }
    val currentPromoCode by rememberUpdatedState(promoCode)

    val onCheckout = remember(viewModel) {
        { viewModel.checkout(currentPromoCode) }
    }

    CartHeader(summary = summary, onCheckout = onCheckout)
}
Enter fullscreen mode Exit fullscreen mode

remember(viewModel) now holds the same lambda instance across recompositions (it only rebuilds if viewModel itself changes), while currentPromoCode — read inside the lambda body, not captured at creation — always resolves to whatever the latest promo code is when checkout actually fires. CartHeader now only recomposes when summary changes, which is the only thing it was ever supposed to care about.

Why this matters more as screens get bigger

A two-line composable eating an extra recomposition is noise. The same pattern inside a LazyColumn item scope — a row-level onClick capturing a loop variable or a per-item ViewModel field — means every keystroke in an unrelated search box or filter chip can ripple into rebuilding every visible row, because each row's lambda was freshly allocated on the parent's last pass. The fix is identical: hoist the lambda out of the loop body with remember(key), and let it read current values through rememberUpdatedState or a stable callback object instead of closing over them directly.

The check to run before blaming @Stable

If Layout Inspector shows a composable recomposing on a state change that has nothing to do with the data it renders, don't start by re-annotating your model classes. Check what's being passed as a trailing lambda or callback first, and whether that lambda is remember-ed or rebuilt fresh on every pass. Stability of the data you already got right; it's the stability of the behavior you hand down alongside it that's usually the actual leak.


This is one slice of a much bigger surface — Compose's stability inference rules, how the slot table actually decides what counts as "changed," and the handful of other non-obvious recomposition triggers that show up constantly in senior Android interviews — covered in full in Jetpack Compose & UI Architecture: Ultimate Interview Prep Blueprint if you want the deeper dive, including the Google-interviewer-specific framing for how this gets asked in a whiteboard setting.

Top comments (0)