How to Avoid Recomposition Loops in Jetpack Compose

Pavel Bo
3 min readNov 14, 2024

--

Photo by Tine Ivanič on Unsplash

Jetpack Compose is a powerful tool that simplifies creating UIs in Android, but it also presents a learning curve for developers. Many encounter unexpected behavior and issues that may not seem obvious at first glance. In this article, we’ll look at one such issue — how to prevent recomposition from getting stuck in a loop in Compose.

Example Code

Let’s start with an example:

data class MyDataClass(  
val i: Int = 0,
val block: () -> Unit = {},
)

class MyScreenViewModel : ViewModel() {
private val dataSource = MutableSharedFlow<Int>(1)

val stateValue: StateFlow<MyDataClass>
get() = dataSource
.map { number ->
MyDataClass(number, { println("Hello, World!") })
}
.stateIn(viewModelScope, SharingStarted.Eagerly, MyDataClass())
}

@Composable
fun MyScreen(viewModel: MyScreenViewModel) {
Log.d("[TAG]", "Recomposition!")

val state by viewModel.stateValue.collectAsStateWithLifecycle()
val checked = remember { mutableStateOf(false) }

Column {
Checkbox(
checked = checked.value,
onCheckedChange = { isChecked -> checked.value = isChecked }
)
Text("state: ${state.i}")
}
}

At first glance, this code seems fine. But if you run it, toggle the checkbox, and check LogCat or Layout Inspector, you’ll see that recomposition is stuck in a loop.

Recomposition Loops

Why does this happen? Let’s investigate.

Understanding the Problem

The core of the problem lies in the collectAsStateWithLifecycle() extension function. Here’s what it looks like under the hood:

@Composable  
fun <T> Flow<T>.collectAsStateWithLifecycle(
initialValue: T,
lifecycle: Lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
context: CoroutineContext = EmptyCoroutineContext
): State<T> {
return produceState(initialValue, this, lifecycle, minActiveState, context) {
lifecycle.repeatOnLifecycle(minActiveState) {
if (context == EmptyCoroutineContext) {
this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
} else withContext(context) {
this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
}
}
}
}

@Composable
fun <T> produceState(
initialValue: T,
vararg keys: Any?,
producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
val result = remember { mutableStateOf(initialValue) }
@Suppress("CHANGING_ARGUMENTS_EXECUTION_ORDER_FOR_NAMED_VARARGS")
LaunchedEffect(keys = keys) {
ProduceStateScopeImpl(result, coroutineContext).producer()
}
return result
}

The function creates a Compose state and subscribes to the flow using LaunchedEffect, allowing us to observe new values emitted by the flow.

So, now we can see two key issues in our code:

  1. Each time we access stateValue, a new StateFlow instance is created because the get() block in stateValue re-evaluates, which leads to a new LaunchedEffect being triggered in collectAsStateWithLifecycle() and a new subscription being added;
  2. Each new StateFlow instance creates a new MyDataClass instance, which, despite being a data class, is not comparable due to the lambda function inside it. Consequently, Compose treats each new instance as a distinct value, triggering a recomposition.

Why Lambdas Are Not Comparable

A simple expression like:

{ println("Hello, World!") } != { println("Hello, World!") }

may look surprising but illustrates the issue here. Lambdas in Kotlin are instances of FunctionX interfaces (where X represents the number of parameters). Thus, two FunctionX instances are only considered equal by reference, not by content.

// An example of a FunctionX: Function3 with 3 input parameters

public interface Function3<in P1, in P2, in P3, out R> : kotlin.Function<R> {
public abstract operator fun invoke(p1: P1, p2: P2, p3: P3): R
}

Solution

To fix this problem, we have two options:

  1. Exclude the lambda from equals and hashCode calculations in MyDataClass. This prevents unnecessary recompositions by ensuring MyDataClass(1, {}) objects are seen as equal;
  2. Make stateValue stable by avoiding the re-evaluation of the get() function, so it does not create a new StateFlow instance on each access.

The most optimal solution is to combine both approaches, as shown below:

data class MyDataClass(  
val i: Int = 1,
val block: () -> Unit = {},
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

other as MyDataClass
return i == other.i
}

override fun hashCode(): Int {
return i
}
}

class MyScreenViewModel : ViewModel() {
private val dataSource = MutableSharedFlow<Int>(1)

val stateValue: StateFlow<MyDataClass> = dataSource
.map { number -> MyDataClass(number, { println("Hello, World!") }) }
.stateIn(viewModelScope, SharingStarted.Eagerly, MyDataClass())
}

Thank you for reading! If you found this article helpful, please give it an applaud and share your thoughts in the comments. Your feedback helps me improve!

--

--

Pavel Bo
Pavel Bo

Responses (1)