Skip to main content

Eliminating Recomposition Loops in Jetpack Compose using Strong Skipping Mode

 The promise of Jetpack Compose's Strong Skipping Mode (enabled by default in Kotlin 2.0.20+) is seductive: it relaxes the strict stability requirements that previously forced developers to annotate classes with @Stable or use ImmutableList wrappers. With Strong Skipping, the compiler can skip recompositions for unstable parameters as long as their instance references haven't changed.

However, many teams enable this mode and are surprised to find their LazyColumn performance remains degraded. The Layout Inspector still reports unskippable recompositions during scroll or state updates.

The issue isn't the compiler; it is referential integrity. Strong Skipping changes the skip logic from "Is the type Stable?" to "Is the value the same object instance (===)?". If your composition logic implicitly allocates new objects—specifically collections or lambdas—Strong Skipping will fail, and your list will re-render frame-by-frame.

The Root Cause: Implicit Allocations vs. Strong Skipping

Under the hood, Strong Skipping wraps your Composable's body in a conditional block that compares previous parameters to current parameters using instance equality.

The hidden performance killer lies in two common patterns:

  1. Collection Transformations in Composition: If you perform items.filter { ... } or items.sortedBy { ... } directly inside the Composable body, the resulting List is a new object instance on every pass, even if the content is identical. Strong Skipping sees oldList !== newList and forces a recomposition.

  2. Unstable Lambdas: While Strong Skipping memoizes lambdas better than previous versions, lambdas that capture unstable variables (like a var inside a ViewModel or a specific item in a loop) may still result in a new function object allocation. If the lambda instance changes, the child Composable (e.g., a list item) creates a new scope, breaking the skip.

The Solution: Enforcing Referential Integrity

To leverage Strong Skipping effectively, we must ensure that parameters passed to child Composables maintain referential equality across recompositions unless the data actually changes.

Step 1: Verify Configuration

Ensure you are running Kotlin 2.0.0+ where Strong Skipping is available. If you are on an older version (1.9.20+), enable it manually in your module-level build.gradle.kts:

android {
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.14" // or compatible version
    }
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
    compilerOptions {
        // Only needed for Kotlin < 2.0.20. 
        // In 2.0.20+, this is default behavior.
        freeCompilerArgs.add("-P")
        freeCompilerArgs.add("plugin:androidx.compose.compiler.plugins.kotlin:strongSkipping=true")
    }
}

Step 2: The Optimized Implementation

Below is a rigorous implementation of a high-frequency update list (e.g., a Crypto Ticker). We address the allocation trap using remember and derived state, ensuring Strong Skipping succeeds.

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.material3.Button
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.util.UUID

// 1. A typical "Unstable" data class (contains a List).
// Without Strong Skipping, this would never skip.
data class MarketData(
    val id: String = UUID.randomUUID().toString(),
    val symbol: String,
    val price: Double,
    val tags: List<String> // Defines instability in standard Compose
)

@Composable
fun CryptoDashboard(
    rawData: List<MarketData>,
    onItemClick: (String) -> Unit
) {
    // CRITICAL FIX 1: Prevent Collection Re-allocation
    // We filter the list. Doing this directly in the LazyColumn would 
    // create a new ArrayList instance on every parent recomposition,
    // causing Strong Skipping to fail (New Ref != Old Ref).
    val activeItems by remember(rawData) {
        derivedStateOf { 
            rawData.filter { it.price > 0.0 } 
        }
    }

    // CRITICAL FIX 2: Stable Lambda Reference
    // Even with Strong Skipping, passing `it` directly in a lambda 
    // inside `items` can sometimes trigger re-allocation if context changes.
    // We create a dedicated handler to maintain function reference integrity.
    val handleItemClick: (String) -> Unit = remember(onItemClick) {
        { id -> onItemClick(id) }
    }

    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp)
    ) {
        items(
            items = activeItems,
            key = { it.id } // Always provide a stable key
        ) { item ->
            TickerRow(
                data = item,
                onClick = handleItemClick
            )
        }
    }
}

@Composable
fun TickerRow(
    data: MarketData,
    onClick: (String) -> Unit
) {
    // With Strong Skipping enabled, this Composable will now SKIP
    // even though 'MarketData' contains a List (unstable),
    // provided the 'data' reference passed in hasn't changed.
    
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp)
    ) {
        Text(text = data.symbol, modifier = Modifier.weight(1f))
        Text(text = "$${data.price}")
        Button(onClick = { onClick(data.id) }) {
            Text("Trade")
        }
    }
}

Why This Fix Works

1. derivedStateOf vs. Direct Transformation

In the "bad" scenario, developers often write:

// BAD
LazyColumn {
    items(rawData.filter { ... }) { ... }
}

In Kotlin, .filter creates a new ArrayList. Even if rawData hasn't changed, the input to items is a new memory address. Strong Skipping compares the old address to the new address, sees a mismatch, and recomposes every item.

By using remember(rawData) { derivedStateOf { ... } }, we guarantee that as long as the rawData reference remains the same, the activeItems reference also remains exactly the same. The equality check current === previous passes.

2. Lambda Memoization

While Strong Skipping improves lambda handling, passing a lambda that captures a varying scope inside a LazyColumn loop can be risky.

// RISKY
items(items) { item ->
    TickerRow(data = item, onClick = { onItemClick(item.id) })
}

Here, the lambda captures item. If item changes, the lambda is recreated. More importantly, if the parent recomposes, the compiler might generate a new anonymous class for the lambda.

By passing a generic (String) -> Unit handler (handleItemClick) that is remembered at the parent level, we pass the exact same function reference to every child, every time. The child component (TickerRow) invokes the callback with its specific ID, but the onClick parameter itself never changes, allowing the row to skip recomposition entirely.

Conclusion

Strong Skipping Mode is a powerful tool, but it is not a magic wand that fixes poor memory management. It shifts the responsibility from Type Stability (interfaces vs. implementations) to Reference Stability.

To eliminate jank in complex LazyLayouts:

  1. Stop allocating new collections (filter/map/sort) directly in the composition body.
  2. Use remember or derivedStateOf to cache the results of these transformations.
  3. Ensure your callbacks are referentially stable.

When you satisfy these conditions, Strong Skipping allows you to use standard List<T> and data classes without @Stable annotations, finally achieving the smooth scroll performance usually reserved for heavily optimized builds.