Firing Side Effects from Compose using SnapshotFlow

Efe Ejemudaro
6 min readFeb 29, 2024

In this article, we’ll be going through approaches of observing components’ states in composables; specifically the cases where there is no plan of modifying the UI with this state; the sole purpose of this state’s observation is to fire some side effects from composables, such as logging analytics events. Some examples of these events may be

  • Observing the current scroll position in a Pager.
  • Observing what indexes of a Lazy List are currently visible to the User

We’ll be going through approaches of observing state in this context, the problems each approach may bring about, and of course the recommended approach.

Recomposition

If you are reading this, there is an assumption you have a basic understanding of what Jetpack Compose is and how it works, as well as what Recomposition is. However, here’s a summary.

Recomposition is the backbone of Compose. It is the art of re-calling a Composable with new data to display an updated state to the user. To do this, Composables depend on MutableState objects, observing them and queueing a new recomposition when a new value is emitted. As developers, the goal is to make sure the recompositions don’t occur when they shouldn’t; limiting the number of times they occur to the absolute minimum. If they get out of control, they could result in bad performance.

Our Case Problem

Let's say we have a Lazy List in our composable and we want to observe the visible indexes, sending them to the ViewModel via a lambda to fire analytics event with this piece of data. Here is what our code currently looks like

setContent {
MyList(
items = List(20) { it.toString() },
onVisibleItemsChanged = {
// Send to ViewModel
}
)
}
@Composable
fun MyList(
items: List<String>,
onVisibleItemsChanged: (List<Int>) -> Unit,
) {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
items(items = items) { MyItem(it) }
}
}

The Obvious Approach

Directly, we can access the information we need from the list state and send that back via the lambda. That would look something like this.

...
val state = rememberLazyListState()
val visibleItems = state.layoutInfo.visibleItemsInfo
onVisibleItemsChanged(visibleItems)
...

We get the current visible indices by getting the property from the listState object and then call the lambda with this value. Doing this, we get these logs.

22:02:36.897  E  [0, 1, 2, 3, 4, 5]
22:02:36.913 E I am recomposed
22:02:36.913 E [0, 1, 2, 3, 4, 5]
22:02:36.930 E I am recomposed
22:02:36.930 E [0, 1, 2, 3, 4, 5]
22:02:36.947 E I am recomposed
22:02:36.948 E [0, 1, 2, 3, 4, 5]
22:02:36.965 E I am recomposed
22:02:36.966 E [0, 1, 2, 3, 4, 5]
22:02:36.981 E I am recomposed
22:02:36.981 E [0, 1, 2, 3, 4, 5]

As seen, there are visible problems with this approach. The composable keeps recomposing, sending the same indexes a lot of times. This is because we are directly interacting with a state object that can change often in our composable. As soon as it changes, it causes a recomposition which causes the state to reemit (the same thing, yes), which causes a recomposition and yes, we are stuck in an infinite loop. Android Studio itself already outputs some warning when this is done.

Hence, We need to reduce / eliminate the recompositions due to this visible state changes.

Using Derived State

Android Studio already gives us a suggestion in the warning captured in the screenshot above. It suggests that Derived State should be used. What is Derived State and how does it help in this scenario?

Derived State is an object that provides a state from a calculation that only triggers a recomposition when this calculation returns a different value. i.e. If the same value is returned from the calculation wrapped by derived state, the parent recomposition wont be recomposed due to this state object.

To use derived state for our problem, we will need to wrap our visible indices calculation with derivedStateOf. It would also be very useful to wrap this derived state with a remember to ensure the same state object returned by the derived state API is used across all recompositions. Our code becomes something like this.

...
val visibleItems by remember {
derivedStateOf {
listState.layoutInfo.visibleItemsInfo.map { it.index }
}
}
...

In theory, this should stop the recursive recompositions we had in the previous solution. Our Composable should now only be recomposed when the visible indices in the list state changes. Running this piece of code, we get these logs when the list is scrolled.

14:42:11.195  E  I am recomposed
14:42:11.198 E []
14:42:11.280 E I am recomposed
14:42:11.281 E [0, 1, 2, 3, 4, 5]
14:42:19.942 E I am recomposed
14:42:19.943 E [1, 2, 3, 4, 5, 6]
14:42:19.965 E I am recomposed
14:42:19.965 E [1, 2, 3, 4, 5, 6, 7]
14:42:19.978 E I am recomposed
14:42:19.978 E [2, 3, 4, 5, 6, 7]
14:42:20.001 E I am recomposed
14:42:20.001 E [3, 4, 5, 6, 7, 8]
14:42:20.020 E I am recomposed
14:42:20.021 E [3, 4, 5, 6, 7, 8, 9]
14:42:20.053 E I am recomposed

As seen above, our solution is already much better and we have been able to get rid of the infinite recompositions we had with the previous solution. The lambda is also only called when the visible indices actually changes.

However, is this the best we can do? No. Recompositions should only happen when we have a data change in our composable effecting a change in the UI being displayed to the user; which doesn’t really apply in this scenario. The state being observed doesn’t actually result in a change in the user interface being displayed. The sole purpose is to send an analytic event which means that we don’t want a recomposition of the interface due to this reason. We need another API for this purpose.

Using Snapshot Flow

In comes Snapshot Flow to save the day. This works quite similar to the Derived state API except that a flow object is returned instead of a state object; This returned flow object can be collected inside a coroutine scope to be used as wished.

The major difference with this API when compared with the Derived State implementation is pretty much that one returns a State object and the other returns a Flow object. In the context of a composable, only State objects can trigger recompositions, so flows emitting different values will not make a difference to the current composition. To utilise snapshot flow for our problem, we will have something like this.

...
LaunchedEffect(key = Unit) {
snapshotFlow {
listState.layoutInfo.visibleItemsInfo.map { it.index }
}.collect { visibleItems ->
onVisibleItemsChanged(visibleItems)
}
}
...

As seen above, a LaunchedEffect SideEffect is launched with a constant key to open a coroutine scope that will never be restarted for the lifecycle of this composable. In there, we create a snapshot flow that calculates the indices of the visible indices, collects it and calls the lambda that delivers it to the caller of the composable. Running this, we would get something like this in the logs.

14:35:06.560  E  I am recomposed
14:35:06.701 E [0, 1, 2, 3, 4, 5]
14:35:18.357 E [0, 1, 2, 3, 4, 5, 6]
14:35:18.389 E [1, 2, 3, 4, 5, 6]
14:35:18.418 E [1, 2, 3, 4, 5, 6, 7]
14:35:18.446 E [2, 3, 4, 5, 6, 7]
14:35:18.484 E [2, 3, 4, 5, 6, 7, 8]
14:35:18.496 E [3, 4, 5, 6, 7, 8]
14:35:18.535 E [4, 5, 6, 7, 8, 9]
14:35:18.574 E [4, 5, 6, 7, 8, 9, 10]
14:35:18.603 E [5, 6, 7, 8, 9, 10]
14:35:18.636 E [5, 6, 7, 8, 9, 10, 11]
14:35:18.676 E [6, 7, 8, 9, 10, 11]

Doing this, we have now been able to get rid of all the recompositions happening while observing the visible items in our lazy list.

In summary, when trying to observe a state in our composable that does not affect the ui state of our composable, giving no update to the user interacting with the application, snapshot flow should be taken into consideration as it does the job with zero effect on the user experience.

Good luck!

--

--

Efe Ejemudaro

Android & iOS Developer. Loves to write. Tries to Write. Majorly Tech. Sometimes Personal.