Skip to content
All posts
May 12, 20264 min read

Jetpack Compose Performance: Avoiding Unnecessary Recompositions

Compose is fast by design, but it's easy to write composables that recompose far more than necessary. Here's how to identify recomposition problems, understand why they happen, and fix them with the right patterns.

AndroidJetpack ComposePerformanceKotlin
Share:

Compose's recomposition model is simple in theory: when state changes, composables that read that state recompose. In practice, understanding exactly what triggers recomposition — and how to minimize it — requires knowing the specifics.


How Recomposition Works

Compose tracks which state objects a composable reads during composition. When that state changes, the composable recomposes.

kotlin
// This composable reads `count` — it recomposes when count changes
@Composable
fun CountDisplay(count: Int) {
    Text(text = "Count: $count")
}

// This composable reads nothing from state — never recomposes unnecessarily
@Composable
fun StaticLabel(label: String) {
    Text(text = label)
}

The problem: Compose also recomposes composables when their parent recomposes, unless it can determine the parameters haven't changed (stability).


Stability: The Core Concept

Compose marks a type as stable if it can determine that two instances with the same properties will always produce the same composition output. For stable types, Compose can skip recomposition when the parameters haven't changed.

Stable by default:

  • Primitive types (
    code
    Int
    ,
    code
    String
    ,
    code
    Boolean
    ,
    code
    Float
    )
  • Immutable data classes with only stable fields
  • Lambdas (in most cases)

Unstable by default:

  • Mutable data classes (
    code
    var
    fields)
  • Classes from external libraries (Compose can't analyze them)
  • Lists, Maps, Sets (
    code
    List<T>
    is mutable and therefore unstable)

Identifying Recomposition Problems

Enable the Recomposition Counts layout inspector in Android Studio:

Layout Inspector → Live Updates → Enable "Composition Tracking"

Composables that recompose excessively will show high counts. Alternatively, add logging:

kotlin
@Composable
fun TaskItem(task: Task) {
    SideEffect {
        println("TaskItem recomposed for ${task.id}")
    }
    // ...
}

If

code
TaskItem
logs on every keystroke in a search bar that doesn't affect tasks, something's wrong.


Fix 1: Use Stable Data Classes

kotlin
// Unstable — `tasks` is a mutable List
data class TaskUiState(
    val tasks: MutableList<Task> = mutableListOf(),
    val isLoading: Boolean = false
)

// Stable — all fields are immutable
data class TaskUiState(
    val tasks: List<Task> = emptyList(), // Still a mutable List type but...
    val isLoading: Boolean = false
)

// More explicitly stable:
@Immutable // or @Stable
data class TaskUiState(
    val tasks: ImmutableList<Task> = persistentListOf(),
    val isLoading: Boolean = false
)

Use

code
kotlinx.collections.immutable
for
code
ImmutableList
— Compose recognizes it as stable:

kotlin
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")

Fix 2: Don't Read State at the Wrong Level

Read state as low in the tree as possible:

kotlin
// Bad — entire TaskListScreen recomposes when ANY task changes
@Composable
fun TaskListScreen(tasks: List<Task>, searchQuery: String) {
    Column {
        SearchBar(query = searchQuery) // This triggers recomposition of Column
        LazyColumn {
            items(tasks) { task ->
                TaskItem(task = task) // These also recompose
            }
        }
    }
}

// Better — separate reads so recomposition is targeted
@Composable
fun TaskListScreen(viewModel: TaskListViewModel) {
    // Each of these reads its own state independently
    val tasks by viewModel.tasks.collectAsStateWithLifecycle()
    val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
    
    Column {
        SearchBar(query = searchQuery, onQueryChange = viewModel::onQueryChange)
        TaskList(tasks = tasks)
    }
}

Fix 3: Remember Lambdas

Every time a parent recomposes, it creates new lambda instances. Lambdas as composable parameters may trigger unnecessary recomposition if the child thinks its parameters changed:

kotlin
// Bad — new lambda instance on every parent recomposition
@Composable
fun TaskListScreen(viewModel: TaskListViewModel) {
    val tasks by viewModel.tasks.collectAsStateWithLifecycle()
    TaskList(
        tasks = tasks,
        onComplete = { id -> viewModel.completeTask(id) } // New lambda every time
    )
}

// Better — stable lambda reference
@Composable
fun TaskListScreen(viewModel: TaskListViewModel) {
    val tasks by viewModel.tasks.collectAsStateWithLifecycle()
    val onComplete = remember { { id: String -> viewModel.completeTask(id) } }
    TaskList(tasks = tasks, onComplete = onComplete)
}

Or move the lambda to the ViewModel:

kotlin
// cleanest — ViewModel reference is stable
TaskList(tasks = tasks, onComplete = viewModel::completeTask)

Fix 4: Use key() for LazyColumn Items

When your list items can be reordered or replaced, provide stable keys:

kotlin
LazyColumn {
    items(
        items = tasks,
        key = { task -> task.id } // Stable key prevents unnecessary recomposition on reorder
    ) { task ->
        TaskItem(task = task)
    }
}

Without

code
key
, Compose uses position as the identity. Inserting a task at position 0 makes every other item think it's a different task.


Fix 5: derivedStateOf for Computed Values

When state is derived from other state, use

code
derivedStateOf
to prevent recomposition when the computed value doesn't change:

kotlin
// Without derivedStateOf: recomposes on EVERY scroll position change
val showFab = lazyListState.firstVisibleItemIndex > 0

// With derivedStateOf: only recomposes when the boolean result changes
val showFab by remember {
    derivedStateOf { lazyListState.firstVisibleItemIndex > 0 }
}

Layout Inspector: The Definitive Tool

The Layout Inspector in Android Studio 2024+ shows real-time recomposition counts per composable. Numbers in green are healthy. Numbers climbing rapidly or unexpectedly high are problems.

Use it specifically on the screens with most user interaction — list scrolling, typing in search fields, real-time updates.


Takeaways

  • Unstable types (
    code
    List<T>
    , mutable data classes) prevent Compose from skipping recomposition
  • Use
    code
    ImmutableList
    from
    code
    kotlinx.collections.immutable
    for stable list parameters
  • Read state as low in the composable tree as possible — minimize what recomposes when state changes
  • Use
    code
    remember { lambda }
    or
    code
    viewModel::function
    for stable lambda references
  • Provide
    code
    key
    to
    code
    LazyColumn
    items — positions are unreliable as identity
  • code
    derivedStateOf
    prevents recomposition when derived state doesn't actually change
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this