Solving the mystery of recompositions in Compose's LazyList

Hi Composers ๐Ÿ‘‹๐Ÿป, in this blog we'll discuss the issue which generally affects the performance of the application which presents data on UI with the help of Jetpack Compose's LazyList components (LazyColumn or LazyRow). I found this issue while working on my application and I fixed that issue. Later, I got queries about similar issues within the community which motivated me to write about this.

Knowing the issue ๐Ÿž

If you are using Jetpack compose on a regular basis and utilizing LazyList APIs then you might have faced this issue in your app. So Let's build a sample app to demonstrate the issue. At the moment, imagine that we are developing our application with Jetpack Compose 1.4.x APIs. In the sample app, we are simply showing a list in which items are added continuously after some intervals and when clicking on an item in the list, the details are displayed in detail at the bottom of the screen. See the below preview.

Now here's what the code of the List's implementation looks like. Inside of LazyColumn simply all notes are passed as a list and each Note is composed.

Now when the app is run and checked for the recompositions count in the Layout Inspector, here's a surprise ๐Ÿ˜ฎ.

As you can see, each item in the list is getting recomposed when a new item is added to a list ๐Ÿคฏ. For example, a total of 50 items are added to a list then all 50 Composable note items are recomposed when a new (51st) item is added to a list.

After getting such issues, the first thought which comes to mind is "Stability", right? We can quickly verify the stability of models and composable functions. See, all our composable functions are stable. (Verified with this plugin)

Compose Compiler Report generated by https://github.com/PatilShreyas/compose-report-to-html

So what's the issue here? Let's understand


Getting the root cause of the issue

After facing the issue, got a chance to study and investigate this issue in detail and the learnings were interesting.

While experimenting with a lot of code changes, tried one change that fixed the issue. Just note a difference in implementation from the below snippet.

In the previous implementation, Modifier was exposed as a parameter from a Note composable so that list can add behavior to it. Click of an item was handled inside LazyColumn of NotesScreen composable. In the new implementation, instead of exposing a modifier, we have exposed a lambda that takes care of the execution of logic on click, and internally Modifier is handled inside Note composable only without exposing it outside.

Now, after this change, see the recompositions with this new logic.

Surprised๐Ÿคฏ? With this small change, a list item never got recomposed again and it smartly skipped recomposing the items inside the list. Also, this is not the only solution. There are several variants of potential fixes:

  • Remembering a modifier

      @Composable 
      fun NotesScreen(...) {
          // ...
          items(notes) { note ->
              val clickableModifier = remember(note) { 
                 Modifier.clickable { /* Do something */ }
              }
              Note(note = note, modifier = clickableModifier)
          }
      }
    
  • Using a singleton modifier (not usable in our case since we need a note)

      val clickableModifier = Modifier.clickable { /* Do something */ }
    
      @Composable
      fun NotesScreen(...) {
          // ...
          items(notes) { note ->
              Note(note = note, modifier = clickableModifier)
          }
      }
    

But again, the question is "Why it's making a difference?".


To understand more in detail, I even tried using Modifier.pointerInput with old implementation and performing clicks with onTap callback and found that the same issue is not happening with this modifier but only happening with clickable{} Modifier.

So it's not an issue with all Modifiers but only with clickable{} modifier. After checking the implementation of this modifier, we can see that clickable is a composed modifier which makes it different from another modifier.

What is composed Modifier?

Docs says:

Declare a just-in-time composition of a Modifier that will be composed for each element it modifies. composed may be used to implement stateful modifiers that have instance-specific state for each modified element, allowing the same Modifier instance to be safely reused for multiple elements while maintaining element-specific state.

You can see that composed takes a factory which is a @Composable lambda that returns a value. This lambda is executed when layout nodes are created. Composable functions/lambdas that return a value are not skippable and also not automatically remembered by Compose.

Whenever such a modifier is used directly inside the LazyListScope, all existing items are recomposed because a new instance of Modifier (with clickable{}) gets created which is not equal to the previous instance of the modifier. In short, every time the function is called, it'll behave like Modifier is changed and it'll cause recompositions.

It means, if any Modifier that uses composed{} under the hood is used, then it will cause extra recompositions of composable components, despite them having the correct stability. When directly used within LazyListScope, it affects badly because it's a point where each item is laid inside LazyColumn/LazyRow.

The solution is simple to use with LazyListScope

  • Avoid using composed{} modifier directly in LazyListScope

  • If there's any use case that you need to use Modifier in such place, then make sure that modifier is remembered.

But... how can we live with it further? ๐Ÿค”

Definitely, this is a genuine issue and we can't just live with it. That's a reason why the Jetpack Compose team is refactoring Modifiers. In this talk @ Android Dev Summit 2022, Leland Richardson had already discussed the issue in detail and the performance issues with the usage of the composed{} modifier.

Android Dev Summit 2022: Compose Modifiers Deep dive

Also, in the recent release of Jetpack Compose (August 2023), the team has already improved significant performance with the Modifier refactoring and has promised to fix the issues with the Clickable modifier in future releases ๐Ÿ‘‡๐Ÿป.

Source: Android Developers Blog - Whatโ€™s new in the Jetpack Compose August โ€™23 release

So in the end, it's great news that we can soon get rid of this issue in the upcoming versions of Compose with which we won't even need to worry about these scenarios and we won't need to tweak solutions as we did so far. Till the time, we can fix the issues as we discussed ๐Ÿ˜€.


Awesome ๐Ÿคฉ. I hope you learned how can we fix the performance issues so far till the time full refactoring of the Modifiers is officially made done by the Jetpack Compose team.

If you like this write-up, do share it ๐Ÿ˜‰, because...

"Sharing is Caring"

Thank you! ๐Ÿ˜„

Let's catch up on Twitter or visit my site to know more about me ๐Ÿ˜Ž.


Many thanks to Ben Trengrove for helping me understand this issue better and reviewing this blog post ๐Ÿ˜€.


๐Ÿ“šReferences

Did you find this article valuable?

Support Shreyas Patil by becoming a sponsor. Any amount is appreciated!

ย