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 Letter. Dove 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 Compose. HiltViewModel 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:
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
// 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:
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