The most important aspect of the Kotlin flow operator — combine()

A must-know if you use Kotlin flows

The Android Developer

--

Introduction ✍️

Hello everyone 👋 Hope you’re doing well! In this article, I will talk about a very important aspect of the combine() flow operator. This is a concept that many people don’t realize until they encounter a bug. Understanding it could potentially save you tons of debugging time.

Pre-requisites 📝

This blog is mainly for the people who are familiar with the fundamentals of Kotlin flows. If you’re familiar with Kotlin flows and its underlying concepts, then this blog is for you.

About the example used in this blog 💍

I always like to explain things with examples. I believe learning this way would really cement the concept being learned. So, I’m gonna do the same thing in this article.

In this article, let's imagine that we’re building the home screen of a weather app called “Just Weather”, that displays a list of saved locations with the current weather for each location. Here’s how the app looks.

Screenshot of the sample weather app

The combine operator 👷

The combine operator is a very common flow operator used to combine the emissions of 2 or more flows into a single flow. This is the ViewModel of the weather app. It uses the combine operator to produce the UI state.

// HomeViewModel.kt 

private val currentSearchQuery = MutableStateFlow("")
private val isLoadingAutofillSuggestions = MutableStateFlow(false)
private val isLoadingSavedLocations = MutableStateFlow(false)
// saved locations are fetched from the local database
private val weatherDetailsOfSavedLocations = weatherRepository.getSavedLocationsListStream()
// whenever the current search query changes, this flow will fetch the suggested places
// for that query.
private val autofillSuggestions = currentSearchQuery.debounce(250)
.distinctUntilChanged()
.filter { it.isNotBlank() }
.mapLatest { query ->
isLoadingAutofillSuggestions.value = true
locationServicesRepository.fetchSuggestedPlacesForQuery(query)
.also { isLoadingAutofillSuggestions.value = false }
}

val uiState = combine(
isLoadingSavedLocations, // state flow
isLoadingAutofillSuggestions, // state flow
weatherDetailsOfSavedLocations, // flow
autofillSuggestions // flow
) { isLoadingSavedLocations, isLoadingAutofillSuggestions, weatherDetailsOfSavedLocations, autofillSuggestions ->
HomeScreenUiState(.....)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(300),
initialValue = HomeScreenUiState(isLoadingSavedLocations = true)
)

The combine operator combines 4 flows and then emits a new UI state. The combine operator works in a way such that it runs its transform block whenever any of the flows passed to it emits a new value. The first two flows — isLoadingSavedLocations and isLoadingAutofillSuggestions are pretty self-explanatory. I’ll go over what the next two flows represent.

weatherDetailsOfSavedLocations — It is a flow that emits a list of locations saved by the user. In this case, the list of saved items comes from the room database.

autofillSuggestions- This flow returns a list of autofill suggestions for the currentSearchQuery.

There’s one more thing to keep in mind. The first two flows are normal Flows. The next two flows are StateFlows.

The Gotcha 🙇‍♂️

So, everything looks good, right? Let’s run the app and see what happens.

Hmm…that’s interesting. The app seems to get stuck loading the weather details for the saved locations. It also seems that it shows the list of saved locations only when we search for a location and then return back. Let’s try logging the transform block of the combine operator and see if the UI state gets produced properly.

val uiState = combine(
isLoadingSavedLocations,
isLoadingAutofillSuggestions,
weatherDetailsOfSavedLocations,
autofillSuggestions
) { isLoadingSavedLocations, isLoadingAutofillSuggestions, weatherDetailsOfSavedLocations, autofillSuggestions ->
Timber.d("Transform block called.") // logging the transform block
HomeScreenUiState(.....)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(300),
initialValue = HomeScreenUiState(isLoadingSavedLocations = true)
)
Transform block gets called only after a search for a location is made

Notice the logcat output. The combine operator’s transform block doesn’t even get called until we search for a location and then come back. That's a very weird bug, isn’t it? Isn’t the combine operator supposed to execute its transform block every time any of the flow’s passed to it, emits a new value? Why doesn’t the emission of the weatherDetailsOfSavedLocations flow, trigger the transform block?

The Why 📖

To understand why this happens, you have to understand a fundamental concept about how the combine operator works.

The combine operator waits for all flows to emit at least one value before it starts combining them. So, the first call of the combine operator's transform block will happen only when all the flows passed to the combine block have emitted at least a single value.

This is an extremely crucial detail that one has to remember when working with the combine operator. If you did not know about this, you’d spend tons of time wondering why the combine operator doesn’t work. This also logically makes sense. How would the transform block get executed when a flow passed to the combine operator hasn’t even emitted a single value? It’s also important to note that StateFlows are kind of an exception because they always get initialized with a value. So they are guaranteed to emit at least 1 value.

The fix 🛠️

This explains why the UI state wasn’t being updated. In our sample app, isLoadingSavedLocations & isLoadingAutofillSuggestions are StateFlows, so they already have a value in them (since we initialize them with a value). But weatherDetailsOfSavedLocations and autofillSuggestions are normal flows, so they don’t have an initial value. Hence, the combine operator will wait for the aforementioned normal flows to emit at least one value before calling the transform block for the first time, even if the values of the other two StateFlows get updated. This implies that if the two normal flows never emit any values, then any updates to the other two StateFlows won’t trigger the transform block of the combine operator. This explains why we never get the new UI state.

// HomeViewModel.kt    
val uiState = combine(
isLoadingSavedLocations, // state flow
isLoadingAutofillSuggestions, // state flow
weatherDetailsOfSavedLocations, // flow
autofillSuggestions // flow
) { isLoadingSavedLocations, isLoadingAutofillSuggestions, weatherDetailsOfSavedLocations, autofillSuggestions ->
HomeScreenUiState(.....)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(300),
initialValue = HomeScreenUiState(isLoadingSavedLocations = true)
)

Do you remember how the UI state gets updated when we searched for a new location? Why does the UI state update properly when we search for a location and then come back?

Now that we understand why the combine block doesn’t produce the new state, it’ll be easy to understand why this happens. It happens because the autofillSuggestions flow doesn’t make its first emission until the search query is changed for the first time. Hence, the combine operator will wait for the autofillSuggestions flow to make its first emission before calling the transform block for the first time.

// HomeViewModel.kt    
val uiState = combine(
isLoadingSavedLocations, // first emission will be the intial value of the state flow
isLoadingAutofillSuggestions, // first emission will be the intial value of the state flow
weatherDetailsOfSavedLocations, // first emission would be the items in the database that'd be automatically fetched by room after a few milliseconds.
autofillSuggestions // **no emission** until the user changes the query text for the first time.
) { isLoadingSavedLocations, isLoadingAutofillSuggestions, weatherDetailsOfSavedLocations, autofillSuggestions ->
HomeScreenUiState(.....)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(300),
initialValue = HomeScreenUiState(isLoadingSavedLocations = true)
)

The easiest way to fix this would be to convert the autofillSuggestions flow, into a StateFlow using the stateIn() operator. This will make the flow have an initial value. Now, even if the user doesn’t search for anything, the combine operator will still execute its transform block because the autofillSuggestions flow will have an initial value. That value would be taken as the first emission of the autofillSuggestions flow, by the combine operator. Let’s make the change in our ViewModel.

// HomeViewModel.kt
private val autofillSuggestions = currentSearchQuery.debounce(250)
.distinctUntilChanged()
.filter { it.isNotBlank() }
.mapLatest { query ->
isLoadingAutofillSuggestions.value = true
locationServicesRepository.fetchSuggestedPlacesForQuery(query)
.also { isLoadingAutofillSuggestions.value = false }
}.stateIn( // convert flow to a StateFlow
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(300),
initialValue = Result.success(emptyList())
)

Now, let's run the app and see if it works.

And, Voila 🎉! We’ve fixed the issue! The UI state gets updated correctly and the list of all saved locations is immediately displayed.

Conclusion ✌️

And, that wraps it up 🎉! Hopefully, you found this blog post helpful. In this article, we looked at a very important aspect of the combine operator. Don’t forget the important takeaway from this article.

The combine operator waits for all flows to emit at least one value before it starts combining them. So, the first call of the combine operator’s transform block will happen only when all the flows passed to the combine block have emitted at least a single value.

As always, I would like to thank you for taking the time to read this article😊. I wish you the best of luck! Happy coding 👨‍💻! Go Create some awesome Android apps 👨‍💻! Cheers!

--

--

The Android Developer

| A very passionate Android Developer 💚 | An extreme Kotlin fanatic 💜 | A huge fan of Jetpack Compose 💙| Focused on making quality blog posts 📝 |