Avoiding Backwards Writes in Jetpack Compose: Optimization Strategies

When developing Android applications using Jetpack Compose, performance is paramount. One subtle yet significant issue that can degrade performance is “backwards writes.” Backwards writes occur when Compose recomposes in an order that results in writing to state variables in an order that causes unnecessary additional recompositions. Understanding and avoiding backwards writes can lead to more efficient and smoother UI updates. This article explains what backwards writes are, how they happen, and provides strategies for preventing them in your Compose code.

What are Backwards Writes?

In Jetpack Compose, recomposition is the process of re-executing composable functions when their input data changes. Backwards writes happen when a recomposition causes a state variable to be updated in a way that, logically, the update is based on a “later” state than it should have been. This incorrect order can trigger further unnecessary recompositions, leading to performance issues. Specifically, backwards writes involve writing to state during recomposition in a way that causes an earlier part of the UI to invalidate again.

Why are Backwards Writes Harmful?

  • Performance Degradation: Unnecessary recompositions waste CPU cycles and can lead to UI jank or lag.
  • Increased Battery Consumption: Frequent recompositions drain the battery faster.
  • Unexpected Behavior: Backwards writes can introduce subtle bugs and make UI behavior unpredictable.

How do Backwards Writes Happen?

Backwards writes typically occur in complex composable functions where state updates are interdependent and the order of execution is not carefully managed. Consider a scenario where two state variables depend on each other and are updated within the same composable.

Example Scenario

Imagine a composable function that manages a list of items and a selection state. Suppose the selection state update depends on the current state of the item list, and this dependency is not correctly managed, potentially leading to backwards writes.


import androidx.compose.runtime.*
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp

data class ListItem(val id: Int, val text: String, var isSelected: Boolean = false)

@Composable
fun ItemList(items: List, onItemSelected: (Int) -> Unit) {
    LazyColumn {
        items(items) { item ->
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(8.dp),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(text = item.text, fontSize = 18.sp)
                Button(onClick = { onItemSelected(item.id) }) {
                    Text(text = if (item.isSelected) "Deselect" else "Select")
                }
            }
        }
    }
}

@Composable
fun BackwardsWriteExample() {
    var itemsState by remember {
        mutableStateOf(
            (1..5).map { ListItem(id = it, text = "Item $it") }
        )
    }

    val onItemSelected: (Int) -> Unit = { itemId ->
        itemsState = itemsState.map { item ->
            if (item.id == itemId) {
                item.copy(isSelected = !item.isSelected)
            } else item
        }
    }

    Column {
        Text(text = "Item List Example", fontSize = 20.sp, modifier = Modifier.padding(8.dp))
        ItemList(items = itemsState, onItemSelected = onItemSelected)
    }
}


@Preview(showBackground = true)
@Composable
fun PreviewBackwardsWriteExample() {
    BackwardsWriteExample()
}

In this example, when an item is selected, the itemsState is updated. Naively, the state updates trigger recomposition, and in some complex UI scenarios or under certain conditions, the selection update may occur in an order that isn’t ideal, causing the list to potentially recompose multiple times due to the updated selection state.

Strategies for Avoiding Backwards Writes

To avoid backwards writes and ensure efficient recompositions, consider the following strategies:

1. Use Derived State

Derived state is state that is computed from other state variables. By deriving state, you can ensure that updates are consistent and avoid unnecessary recompositions. For example, instead of directly updating a flag based on a complex calculation, derive it:


import androidx.compose.runtime.*
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp

data class ListItem(val id: Int, val text: String, var isSelected: Boolean = false)

@Composable
fun ItemList(items: List, onItemSelected: (Int) -> Unit) {
    LazyColumn {
        items(items) { item ->
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(8.dp),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(text = item.text, fontSize = 18.sp)
                Button(onClick = { onItemSelected(item.id) }) {
                    Text(text = if (item.isSelected) "Deselect" else "Select")
                }
            }
        }
    }
}

@Composable
fun DerivedStateExample() {
    var itemsState by remember {
        mutableStateOf(
            (1..5).map { ListItem(id = it, text = "Item $it") }
        )
    }

    val onItemSelected: (Int) -> Unit = { itemId ->
        itemsState = itemsState.map { item ->
            if (item.id == itemId) {
                item.copy(isSelected = !item.isSelected)
            } else item
        }
    }

    Column {
        Text(text = "Item List Example", fontSize = 20.sp, modifier = Modifier.padding(8.dp))
        ItemList(items = itemsState, onItemSelected = onItemSelected)
    }
}


@Preview(showBackground = true)
@Composable
fun PreviewDerivedStateExample() {
    DerivedStateExample()
}

By using derivedStateOf, the selectedItemsCount state is updated only when the actual selection state changes, rather than on every recomposition.

2. Minimize State Read/Write Dependencies

Reduce the dependencies between different state variables. If one state variable does not truly need to depend on another, decouple them to avoid cascading recompositions.

3. Batch State Updates

If multiple state variables need to be updated in response to a single event, batch the updates together. This can prevent intermediate states from triggering unnecessary recompositions.


@Composable
fun BatchedStateUpdates() {
    var count1 by remember { mutableStateOf(0) }
    var count2 by remember { mutableStateOf(0) }

    Column {
        Text("Count1: $count1, Count2: $count2")
        Button(onClick = {
            count1++
            count2++
        }) {
            Text("Increment Both")
        }
    }
}

Instead, use a single state object that encapsulates both values, ensuring they are updated together:


data class CounterState(val count1: Int = 0, val count2: Int = 0)

@Composable
fun BatchedStateUpdatesOptimized() {
    var counterState by remember { mutableStateOf(CounterState()) }

    Column {
        Text("Count1: ${counterState.count1}, Count2: ${counterState.count2}")
        Button(onClick = {
            counterState = counterState.copy(count1 = counterState.count1 + 1, count2 = counterState.count2 + 1)
        }) {
            Text("Increment Both")
        }
    }
}

4. Optimize Data Structures

Use efficient data structures for state variables. For example, use immutable data structures where possible to avoid unintentional modifications. If you’re using mutable lists, ensure that you’re not triggering recompositions unnecessarily by making copies or using appropriate update strategies.

Conclusion

Avoiding backwards writes in Jetpack Compose is crucial for writing efficient and performant Android applications. By understanding the causes of backwards writes and employing strategies such as using derived state, minimizing state dependencies, batching updates, and optimizing data structures, you can significantly reduce unnecessary recompositions and improve your app’s responsiveness. Careful state management is key to harnessing the full potential of Jetpack Compose and delivering a smooth user experience.