Blog Infos
Author
Published
Topics
, , , ,
Published
Unsplash@anniespratt

When a user enters a screen, the default data should be fetched by triggering the business logic, whether from the network or local database. It’s essential to select the right trigger point to avoid side effects, while also preserving the data as state to handle configuration changes in Android.

We’ve previously covered this topic, specifically comparing approaches for loading initial data — whether to use LaunchedEffect or ViewModel—in the post titled Loading Initial Data in LaunchedEffect vs. ViewModel“. After publishing the article, I received a ton of questions regarding specific conditions that make it challenging to apply the lazy observation approach using Flow for everything.

This article aims to clear up (hopefully most of) your doubts about that scenario by addressing the questions one by one. This topic has been raised and featured on Dove LetterDove Letter is a subscription repository where you can learn, discuss, and share new insights about Android and Kotlin. If you’re interested in joining, be sure to check out “Learn Kotlin and Android With Dove Letter.”

1. What if you want to pass arguments when loading initial data

This is one of the most common questions I’ve encountered in the community, and I can easily understand why — it’s a situation we often face. Imagine you have two screens: main and details. When you click on a list item in the main screen, it navigates to the details screen, passing along specific data, such as an ID or other identifying information related to the item in the details screen.

You might feel uncertain about how to pass data to your ViewModel before loading initial data, as in the scenario above. In such cases, you can leverage SavedStateHandle in combination with Hilt and Navigation ComposeHiltViewModel supports constructor injection for SavedStateHandle by default, as it provides two built-in bindings: SavedStateHandle and ViewModelLifecycle, as specified in the internal ViewModel component builder and HiltViewModelFactory.

As shown in the code below, the Hilt ViewModel allows you to inject SavedStateHandle directly into the constructor, which holds the arguments passed from the main screen. The task for fetching details from the network will be triggered correctly when the argument is successfully retrieved and there is any active subscriber from the UI side.

// https://github.com/skydoves/pokedex-compose
@HiltViewModel
class DetailsViewModel @Inject constructor(
detailsRepository: DetailsRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
val pokemon = savedStateHandle.getStateFlow<Pokemon?>("pokemon", null)
val pokemonInfo: StateFlow<PokemonInfo?> =
pokemon.filterNotNull().flatMapLatest { pokemon ->
detailsRepository.fetchPokemonInfo(
name = pokemon.nameField.replaceFirstChar { it.lowercase() },
onComplete = { uiState.tryEmit(key, DetailsUiState.Idle) },
onError = { uiState.tryEmit(key, DetailsUiState.Error(it)) },
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null,
)
}

How does this magic happen? It’s the result of a seamless integration of Jetpack Navigation, Navigation Compose, lifecycle-viewmodel-savedstate, and Hilt libraries, which simplify the entire process. The SavedStateViewModelFactory is used by the default ViewModel factory within the NavBackStackEntry, and the NavHost delegates the appropriate NavBackStackEntry that corresponds to the current destination.

The given NavBackStackEntry is used to create Hilt ViewModel since it provides its dedicated ViewModelFactory, and then it is used to create HiltViewModelFactory. Finally, HiltViewModelFactory injects the SavedStateHandle when it creates the hilt ViewModel.

So when you pass arguments either as part of the route or with type safety, you can easily retrieve them via the NavBackStackEntry.toRoute<T>() or SavedStateHandle.toRoute<T>() methods like the example below:

sealed interface PokedexScreen {
@Serializable
data object Home : PokedexScreen
@Serializable
data class Details(val pokemon: Pokemon) : PokedexScreen {
companion object {
val typeMap = mapOf(typeOf<Pokemon>() to PokemonType)
}
}
}
fun NavGraphBuilder.pokedexNavigation() {
composable<PokedexScreen.Home> {
PokedexHome(this)
}
composable<PokedexScreen.Details>(
typeMap = PokedexScreen.Details.typeMap,
) { backStackEntry ->
val id = backStackEntry.savedStateHandle.get<String>("id")
PokedexDetails(this, id)
}
}

Alternatively, you can inject SavedStateHandle that holds the arguments directly into your ViewModel using constructor injection. This process is demonstrated in the example below:

@Composable
fun PokedexDetails(
// SavedStateHandle with the argument "pokemon" will be injected
detailsViewModel: DetailsViewModel = hiltViewModel(),
) {
..
}
@HiltViewModel
class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : ViewModel() {
..
}

To explore the complete source code, visit the Pokedex Compose open-source repository on GitHub. It’s also helpfult to watch the video Navigation Compose meets Type Safety by the Android team.

2. What if you want to refresh?

This is also one the most common scenarios where you may need to refetch data if the response fails, allowing the user to retry and improve their overall experience.

Actually, this question goes beyond the current topic, as a refresh typically implies performing an action after initialization is complete. While I won’t dive into the specifics of what ‘initial’ means here, you can easily implement this by combining another MutableStateFlow by using the mapLatest or flatMapLatest.

Ian Lake from Google’s Android Toolkit team added with a very clear example below:

This is the most simple and clear way to re-launch a flow by conjunction with another MutableStateFlow and triggering the execution again. If you’re looking for a more elegant solution, you can implement your own StateFlow, called RestartableFlow.

For this, a great solution has already been published in this article, which explains how to create a restartable flow. Instead of diving into the details, let’s jump straight to the code.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Kobweb:Creating websites in Kotlin leveraging Compose HTML

Kobweb is a Kotlin web framework that aims to make web development enjoyable by building on top of Compose HTML and drawing inspiration from Jetpack Compose.
Watch Video

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kobweb

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author o ...

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kob ...

Jobs

// RestartableStateFlow that allows you to re-run the execution
interface RestartableStateFlow<out T> : StateFlow<T> {
fun restart()
}
interface SharingRestartable : SharingStarted {
fun restart()
}
// impementation of the sharing restartable
private data class SharingRestartableImpl(
private val sharingStarted: SharingStarted,
) : SharingRestartable {
private val restartFlow = MutableSharedFlow<SharingCommand>(extraBufferCapacity = 2)
// combine the commands from the restartFlow and the subscriptionCount
override fun command(subscriptionCount: StateFlow<Int>): Flow<SharingCommand> {
return merge(restartFlow, sharingStarted.command(subscriptionCount))
}
// stop and reset the replay cache and restart
override fun restart() {
restartFlow.tryEmit(SharingCommand.STOP_AND_RESET_REPLAY_CACHE)
restartFlow.tryEmit(SharingCommand.START)
}
}
// create a hot flow, which is restartable by manually from a cold flow
fun <T> Flow<T>.restartableStateIn(
scope: CoroutineScope,
started: SharingStarted,
initialValue: T
): RestartableStateFlow<T> {
val sharingRestartable = SharingRestartableImpl(started)
val stateFlow = stateIn(scope, sharingRestartable, initialValue)
return object : RestartableStateFlow<T>, StateFlow<T> by stateFlow {
override fun restart() = sharingRestartable.restart()
}
}

If you examine the SharingRestartableImpl, it merges two flows from restartFlow and subscriptionCount, allowing it to function like a regular StateFlow , but you can stop and reset the replay cache and restart the execution by using the restart() method. The sharing parameter is applied through the Flow<T>.restartableStateIn extension, and then a RestartableStateFlow is created by delegating to the stateFlow instance. This allows you to restart flow execution within your ViewModels, as demonstrated in the example below:

@HiltViewModel
class MainViewModel @Inject constructor(
repository: TimelineRepository
): ViewModel() {
val timelineUi: RestartableStateFlow<ScreenUi?> = repository.fetchTimelineUi()
.flatMapLatest { response -> flowOf(response.getOrNull()) }
.restartableStateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = null
)
// This can be launched from UI side, such as LaunchedEffect or anywhere.
fun restartTimeline() {
timelineUi.restart()
}
}

As shown in the example above, you can restart the execution of repository.fetchTimelineUi() by calling the restartTimeline() function. This function can be triggered from the UI side, such as within a LaunchedEffect in Jetpack Compose, or from any other part of the app.

Again, restarting, retrying, or launching tasks based on user inputs are out of the scope of this discussion. We’re focusing on loading the *initial* data, not on tasks that occur after the first initialization process is complete.

3. Why are ViewModel.init side-effects potentially problematic?

In our previous discussion, we covered how loading initial data in ViewModel.init() can introduce side effects during the ViewModel’s creation, deviating from its primary purpose and complicating lifecycle management.

Let’s revisit the question: why is this an issue? Imagine you manually create a ViewModel inside a composable function without using the viewModel() or hiltViewModel() methods, which internally handle ViewModel persistence and restoration via ViewModelStoreOwner. This could be for reasons like writing unit tests, or something else.

Even if you haven’t triggered any tasks by calling any methods in a ViewModel or subscribed to a cold flow, the ViewModel.init() will still execute its task as soon as the ViewModel is created. This makes the ViewModel more unpredictable, potentially causing unintended behavior and making it much harder to test.

Another important reason in Jetpack Compose is that triggering work in ViewModel.init() using viewModelScope.launch starts execution even if the composition that created the ViewModel is abandoned. This happens because the ViewModel is unaware of the composition lifecycle, leading to unintended executions, and eventually makes harder to test and debug. Ian Lake also added a comment for this about the parallel reason below:

Thinking in Compose

 

Composable functions can run in parallel, meaning they might execute on a pool of background threads. If a composable function interacts with a ViewModel, Compose could potentially call that function from multiple threads simultaneously.

If you scope your ViewModels within specific composable functions, rather than a broader scope like a navigation graph or Activity — whether for reasons such as scoping ViewModels into composable functions for bottom sheets, dialogs, or encapsulated views — any task launched from ViewModel.init() may continue running on the background thread even after the composition is terminated because ViewModels are unaware of composable lifecycles.

4. How to prevent re-emitting flow from WhileSubscribed(5_000)

In the previous post, we discussed how loading initial data in LaunchedEffect and ViewModel.init() are both considered anti-patterns, as Ian Lake pointed out.

Instead, he suggested loading initial data using cold or hot flows, combined with stateIn or shareIn, and utilizing SharingStarted.WhileSubscribed as the started parameter. This approach ensures that values are emitted lazily, only when there are active subscriptions on the UI layer. Additionally, using collectAsStateWithLifecycle allows you to safely subscribe to flows within the UI layer, ensuring lifecycle-aware state management.

However, there’s an issue when using this with the Navigation Compose library. As discussed in Section 1, the NavHost delegates to the appropriate NavBackStackEntry corresponding to the current destination. The problem is that the NavBackStackEntry is a LifecycleOwner itself, and it provides a distinct LocalLifecycleOwner depending on the destination within the NavHost.

When navigating from the main screen to the details screen, all StateFlows being collected in composable functions within the main screen using the collectAsStateWithLifecycle method will stop their subscriptions, as the main screen’s lifecycle state is no longer Lifecycle.State.STARTED. If you return back to the main screen after 5 seconds (SharingStarted.WhileSubscribed(5000)), the flow will restart the emitting, and your business logic—such as network requests or other operations—will be triggered again.

In most cases, this isn’t a critical issue, as relaunching business logic — like fetching network data with the same conditions — typically won’t have a significant impact on your application. However, if the task is resource-intensive, it can lead to performance problems.

In this case, you can resolve the issue by creating a custom SharingStarted strategy. This plays a crucial role in controlling the upstream flow, determining whether it should emit or not, which is the key focus for us.

We can draw inspiration from SharingStarted.WhileSubscribed and StateFlow, as our goal is to trigger the upstream flow and execute business logic only once, and do so lazily when there are active subscribers from the UI layer. At the same time, we need to cache the value and replay it whenever a downstream subscriber reappears, ensuring the fetched data is restored even after navigation changes or configuration changes.

We can implement a new SharingStarted strategy called OnetimeWhileSubscribed, as shown in the code below. You never need to analyze the entire code in detail—just focus on lines 15–20. In this code, you’ll see that emission only starts if there are active subscribers and the value hasn’t been collected yet (line 20). Once a value is collected by a subscriber, it won’t emit again; instead, it will simply replay the latest cached value.

// Designed and developed by skydoves (Jaewoong Eum)
public class OnetimeWhileSubscribed(
private val stopTimeout: Long,
private val replayExpiration: Long = Long.MAX_VALUE,
) : SharingStarted {
private val hasCollected: MutableStateFlow<Boolean> = MutableStateFlow(false)
init {
require(stopTimeout >= 0) { "stopTimeout($stopTimeout ms) cannot be negative" }
require(replayExpiration >= 0) { "replayExpiration($replayExpiration ms) cannot be negative" }
}
override fun command(subscriptionCount: StateFlow<Int>): Flow<SharingCommand> =
combine(hasCollected, subscriptionCount) { collected, counts ->
collected to counts
}
.transformLatest { pair ->
val (collected, count) = pair
if (count > 0 && !collected) {
emit(SharingCommand.START)
hasCollected.value = true
} else {
delay(stopTimeout)
if (replayExpiration > 0) {
emit(SharingCommand.STOP)
delay(replayExpiration)
}
emit(SharingCommand.STOP_AND_RESET_REPLAY_CACHE)
}
}
.dropWhile {
it != SharingCommand.START
} // don't emit any STOP/RESET_BUFFER to start with, only START
.distinctUntilChanged() // just in case somebody forgets it, don't leak our multiple sending of START
}

Ultimately, you can use it as shown in the example below:

val pokemon = savedStateHandle.getStateFlow<Pokemon?>("pokemon", null)
val pokemonInfo: StateFlow<PokemonInfo?> =
pokemon.filterNotNull().flatMapLatest { pokemon ->
detailsRepository.fetchPokemonInfo(pokemon.id)
}.stateIn(
scope = viewModelScope,
started = OnetimeWhileSubscribed(5_000),
initialValue = null,
)
Conclusion

In this article, you’ve explored in depth how to pass arguments when loading initial data, implement a flow refresh feature, learn the potential issues of side effects in ViewModel.init, and prevent flow re-emission with WhileSubscribed(5_000). As mentioned earlier, there’s no one-size-fits-all solution, as different projects come with unique business requirements and tech stacks. I hope this article helps clarify your doubts about loading initial data and provides useful insights for your specific case.

This topic initially raised and featured on Dove Letter. If you’d like to stay updated with the latest information through articles and references, tips with code samples that demonstrate best practices, and news about the overall Android & Kotlin ecosystem, check out ‘Learn Kotlin and Android With Dove Letter’.

As always, happy coding!

— Jaewoong

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
Menu